diff --git a/app/src/main/java/com/pilot51/voicenotify/IgnoreReason.kt b/app/src/main/java/com/pilot51/voicenotify/IgnoreReason.kt index 050c86c..2c7f6b5 100644 --- a/app/src/main/java/com/pilot51/voicenotify/IgnoreReason.kt +++ b/app/src/main/java/com/pilot51/voicenotify/IgnoreReason.kt @@ -36,6 +36,7 @@ enum class IgnoreReason( EMPTY_MSG(R.string.reason_empty_msg), IDENTICAL(R.string.reason_identical), TTS_FAILED(R.string.reason_tts_failed), + TTS_RESTARTED(R.string.reason_tts_restarted), TTS_INTERRUPTED(R.string.reason_tts_interrupted), TTS_LENGTH_LIMIT(R.string.reason_tts_length_limit); diff --git a/app/src/main/java/com/pilot51/voicenotify/NotificationInfo.kt b/app/src/main/java/com/pilot51/voicenotify/NotificationInfo.kt index 1fcafbb..90f1e5d 100644 --- a/app/src/main/java/com/pilot51/voicenotify/NotificationInfo.kt +++ b/app/src/main/java/com/pilot51/voicenotify/NotificationInfo.kt @@ -50,11 +50,15 @@ data class NotificationInfo( private val contentInfoText: String? /** Calendar representing the time that this instance of NotificationInfo was created. */ val calendar: Calendar - /** Set of reasons this notification was ignored. */ - private val ignoreReasons: MutableSet = HashSet() - /** Indicates if the notification was silenced (interrupted). */ - var isSilenced = false - private set + /** Set of reasons this notification was ignored, or an empty set if not ignored. */ + val ignoreReasons = linkedSetOf() + /** + * Indicates if the notification was interrupted during speech. + * Used to set the color of the ignore reasons in [NotifyList]. + * Set to `true` to make the ignore message yellow. + * Default is red for never spoken. + */ + var isInterrupted = false /** The Ignore Repeats setting in seconds, set by [.addIgnoreReasonIdentical]. */ private var ignoreRepeatSeconds = -1 /** The message that was or shall be spoken. */ @@ -139,6 +143,10 @@ data class NotificationInfo( */ fun getIgnoreReasonsAsText(): String { var text = ignoreReasons.joinToString() + // If message ends with period and isn't last, replaces comma after it with newline + .replace("., ", ".\n") + // If message ends with period and isn't first, replaces comma before it with newline + .replace(Regex(", (.+\\.)$"), "\n$1") if (ignoreReasons.contains(IgnoreReason.IDENTICAL)) { text = MessageFormat.format(text, ignoreRepeatSeconds) } @@ -152,14 +160,6 @@ data class NotificationInfo( val time: String get() = SimpleDateFormat("HH:mm:ss", Locale.ENGLISH).format(calendar.time) - /** - * Used to indicate the color of the ignore reasons in [NotifyList]. - * Call this to set it yellow for silenced (interrupted). Default is red for never spoken. - */ - fun setSilenced() { - isSilenced = true - } - /** * Set this notification as ignored for being identical to a previous notification within the configured time * @param ignoreRepeatTime The time in seconds that identical notifications are not to be spoken. @@ -169,29 +169,6 @@ data class NotificationInfo( ignoreReasons.add(IgnoreReason.IDENTICAL) } - /** - * Add a reason this notification was ignored. - * @param reason The ignore reason to add. - */ - fun addIgnoreReason(reason: IgnoreReason) { - ignoreReasons.add(reason) - } - - /** - * Add a set of reasons this notification was ignored. - * @param reasons The ignore reasons to add. - */ - fun addIgnoreReasons(reasons: Set?) { - ignoreReasons.addAll(reasons!!) - } - - /** - * @return Set of reasons this notification was ignored, or an empty set if not ignored. - */ - fun getIgnoreReasons(): Set { - return ignoreReasons - } - // For some reason this is needed to force list state update with simple object copy override fun equals(other: Any?) = super.equals(other) override fun hashCode() = super.hashCode() diff --git a/app/src/main/java/com/pilot51/voicenotify/NotifyList.kt b/app/src/main/java/com/pilot51/voicenotify/NotifyList.kt index 8031b1f..37e5684 100644 --- a/app/src/main/java/com/pilot51/voicenotify/NotifyList.kt +++ b/app/src/main/java/com/pilot51/voicenotify/NotifyList.kt @@ -129,11 +129,11 @@ private fun Item(item: NotificationInfo) { textAlign = TextAlign.Center ) } - if (item.getIgnoreReasons().isNotEmpty()) { + if (item.ignoreReasons.isNotEmpty()) { Text( text = item.getIgnoreReasonsAsText(), modifier = Modifier.fillMaxWidth(), - color = if (item.isSilenced) Color.Yellow else Color.Red, + color = if (item.isInterrupted) Color.Yellow else Color.Red, textAlign = TextAlign.Center ) } diff --git a/app/src/main/java/com/pilot51/voicenotify/Service.kt b/app/src/main/java/com/pilot51/voicenotify/Service.kt index 825d7b4..3a2a991 100644 --- a/app/src/main/java/com/pilot51/voicenotify/Service.kt +++ b/app/src/main/java/com/pilot51/voicenotify/Service.kt @@ -80,8 +80,8 @@ import kotlinx.coroutines.launch import java.util.* class Service : NotificationListenerService() { - private val lastMsg: MutableMap = HashMap() - private val lastMsgTime: MutableMap = HashMap() + private val lastMsg = mutableMapOf() + private val lastMsgTime = mutableMapOf() private var tts: TextToSpeech? = null private var shouldRequestFocus = false private lateinit var audioMan: AudioManager @@ -96,6 +96,10 @@ class Service : NotificationListenerService() { .setLegacyStreamType(AudioManager.STREAM_MUSIC).build()) .build() } else null + private val ttsParams: Bundle + get() = Bundle().apply { + putInt(TextToSpeech.Engine.KEY_PARAM_STREAM, getSelectedAudioStream()) + } /** * this is used to determine if we are the first, middle, or last thing to be spoken at the moment, for enabling/disabling shake and audio focus request @@ -104,13 +108,13 @@ class Service : NotificationListenerService() { * if the list is empty when we finish speaking a message, we untrigger them. */ @SuppressLint("UseSparseArrays") - private val ttsQueue: MutableMap = HashMap() + private val ttsQueue = linkedMapOf() private val statusListener = object : OnStatusChangeListener { override fun onStatusChanged() { if (isSuspended.value && tts != null) { synchronized(ttsQueue) { for (info in ttsQueue.values) { - info.addIgnoreReason(IgnoreReason.SUSPENDED) + info.ignoreReasons.add(IgnoreReason.SUSPENDED) } } tts!!.stop() @@ -119,8 +123,15 @@ class Service : NotificationListenerService() { } override fun onCreate() { + initTts() + super.onCreate() + } + + private fun initTts(onInit: (() -> Unit)? = null) { tts = TextToSpeech(applicationContext, OnInitListener { status -> - if (status != TextToSpeech.SUCCESS) { + if (status == TextToSpeech.SUCCESS) { + onInit?.invoke() + } else { tts = null val errorMsg = getString(R.string.error_tts_init, status) Log.w(TAG, errorMsg) @@ -133,9 +144,9 @@ class Service : NotificationListenerService() { if (interrupted) { val info = ttsQueue[utteranceId.toLong()] if (info != null) { - info.setSilenced() - if (info.getIgnoreReasons().isEmpty()) { - info.addIgnoreReason(IgnoreReason.TTS_INTERRUPTED) + info.isInterrupted = true + if (info.ignoreReasons.isEmpty()) { + info.ignoreReasons.add(IgnoreReason.TTS_INTERRUPTED) } NotifyList.updateInfo(info) } @@ -165,7 +176,38 @@ class Service : NotificationListenerService() { } }) }) - super.onCreate() + } + + private fun restartTts() { + synchronized(ttsQueue) { + ttsQueue.values.forEach { + if (it.ignoreReasons.contains(IgnoreReason.TTS_FAILED)) return@forEach + it.ignoreReasons.add(IgnoreReason.TTS_RESTARTED) + it.isInterrupted = true + NotifyList.updateInfo(it) + } + } + tts?.shutdown() + initTts { + synchronized(ttsQueue) { + val queueIterator = ttsQueue.iterator() + queueIterator.forEach { + val info = it.value + val isFailed = tts!!.speak( + info.ttsMessage, TextToSpeech.QUEUE_ADD, ttsParams, it.key.toString() + ) != TextToSpeech.SUCCESS + if (isFailed) { + Log.e(TAG, "Error adding notification to queue after TTS restart. Not retrying again.") + info.ignoreReasons.add(IgnoreReason.TTS_FAILED) + info.isInterrupted = false + queueIterator.remove() + } else if (info.ignoreReasons.contains(IgnoreReason.TTS_FAILED)) { + info.isInterrupted = true + } + NotifyList.updateInfo(info) + } + } + } } override fun onNotificationPosted(sbn: StatusBarNotification) { @@ -179,11 +221,11 @@ class Service : NotificationListenerService() { val msgTime = info.calendar.timeInMillis val ttsMsg = info.ttsMessage if (app != null && !app.enabled) { - info.addIgnoreReason(IgnoreReason.APP) + info.ignoreReasons.add(IgnoreReason.APP) } if (ttsMsg.isNullOrEmpty() && prefs.getBoolean(KEY_IGNORE_EMPTY, DEFAULT_IGNORE_EMPTY)) { - info.addIgnoreReason(IgnoreReason.EMPTY_MSG) + info.ignoreReasons.add(IgnoreReason.EMPTY_MSG) } if (ttsMsg != null) { val requireStrings = prefs.getString(KEY_REQUIRE_STRINGS, null)?.split("\n") @@ -191,14 +233,14 @@ class Service : NotificationListenerService() { it.isNotEmpty() && !ttsMsg.contains(it, true) } ?: false if (stringRequired) { - info.addIgnoreReason(IgnoreReason.STRING_REQUIRED) + info.ignoreReasons.add(IgnoreReason.STRING_REQUIRED) } val ignoreStrings = prefs.getString(KEY_IGNORE_STRINGS, null)?.split("\n") val stringIgnored = ignoreStrings?.any { it.isNotEmpty() && ttsMsg.contains(it, true) } ?: false if (stringIgnored) { - info.addIgnoreReason(IgnoreReason.STRING_IGNORED) + info.ignoreReasons.add(IgnoreReason.STRING_IGNORED) } } val ignoreRepeat = try { @@ -214,7 +256,7 @@ class Service : NotificationListenerService() { } } NotifyList.addNotification(info) - if (info.getIgnoreReasons().isEmpty()) { + if (info.ignoreReasons.isEmpty()) { val delay = try { prefs.getString(KEY_TTS_DELAY, null) ?.takeIf { it.isNotEmpty() }?.toDouble()?.takeUnless { it < 0.0 } ?: 0.0 @@ -243,7 +285,7 @@ class Service : NotificationListenerService() { if (ignoreReasons.isNotEmpty()) { Log.i(TAG, "Notification ignored for reason(s): " + ignoreReasons.joinToString()) - info.addIgnoreReasons(ignoreReasons) + info.ignoreReasons.addAll(ignoreReasons) return } CoroutineScope(Dispatchers.Main).launch { @@ -268,10 +310,14 @@ class Service : NotificationListenerService() { private fun speak(info: NotificationInfo) { if (tts == null) { Log.w(TAG, "Speak failed due to service destroyed") - info.addIgnoreReason(IgnoreReason.SERVICE_STOPPED) + info.ignoreReasons.add(IgnoreReason.SERVICE_STOPPED) NotifyList.updateInfo(info) return } + if ((info.ttsMessage?.length ?: 0) > TextToSpeech.getMaxSpeechInputLength()) { + info.ignoreReasons.add(IgnoreReason.TTS_LENGTH_LIMIT) + info.isInterrupted = true + } val notificationTime = info.calendar.timeInMillis synchronized(ttsQueue) { if (ttsQueue.isEmpty()) { //if there are no messages in the queue, start up shake detection and audio focus requesting @@ -291,21 +337,18 @@ class Service : NotificationListenerService() { ttsQueue.put(notificationTime, info) } //once the message is in our queue, send it to the real one with the necessary parameters - val audioStream = getSelectedAudioStream() val utteranceId = notificationTime.toString() - val ttsParams = Bundle() - ttsParams.putInt(TextToSpeech.Engine.KEY_PARAM_STREAM, audioStream) val isSpeakFailed = tts!!.speak( info.ttsMessage, TextToSpeech.QUEUE_ADD, ttsParams, utteranceId ) != TextToSpeech.SUCCESS if (isSpeakFailed) { - Log.e(TAG, "Error adding notification to TTS queue") - info.addIgnoreReason(IgnoreReason.TTS_FAILED) - synchronized(ttsQueue) { ttsQueue.remove(notificationTime) } + Log.e(TAG, "Error adding notification to TTS queue. Attempting to restart TTS.") + info.ignoreReasons.add(IgnoreReason.TTS_FAILED) + info.isInterrupted = false + restartTts() } - if ((info.ttsMessage?.length ?: 0) > TextToSpeech.getMaxSpeechInputLength()) { - info.addIgnoreReason(IgnoreReason.TTS_LENGTH_LIMIT) - if (!isSpeakFailed) info.setSilenced() + if (info.ignoreReasons.isNotEmpty()) { + NotifyList.updateInfo(info) } } @@ -393,7 +436,7 @@ class Service : NotificationListenerService() { Log.i(TAG, "TTS silenced by shake") synchronized(ttsQueue) { for (info in ttsQueue.values) { - info.addIgnoreReason(IgnoreReason.SHAKE) + info.ignoreReasons.add(IgnoreReason.SHAKE) NotifyList.updateInfo(info) } } @@ -501,7 +544,7 @@ class Service : NotificationListenerService() { + ignoreReasons.joinToString()) synchronized(ttsQueue) { for (info in ttsQueue.values) { - info.addIgnoreReasons(ignoreReasons) + info.ignoreReasons.addAll(ignoreReasons) } } tts!!.stop() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 42da361..bc895bd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -174,7 +174,8 @@ Silenced by shake Voice Notify suspended by widget Service stopped - TTS failed. Please try restarting the Voice Notify service. + TTS failed. Restart and retry attempted.\nThis text should be yellow if retry successful or red if not.\nPlease try restarting the Voice Notify service if notifications fail to be spoken. + TTS restarted while message in queue or speaking. Re-queued. TTS interrupted for unknown reason Message exceeded TTS length limit