From 5ca983172e2bbab1b78c98a5d4b7ec92dc64ba24 Mon Sep 17 00:00:00 2001 From: gavine99 Date: Tue, 31 Dec 2024 18:24:59 +1100 Subject: [PATCH 1/5] add 'super' font size setting --- domain/src/main/java/com/moez/QKSMS/util/Preferences.kt | 1 + .../main/java/com/moez/QKSMS/common/util/TextViewStyler.kt | 6 ++++++ presentation/src/main/res/values/strings.xml | 1 + 3 files changed, 8 insertions(+) diff --git a/domain/src/main/java/com/moez/QKSMS/util/Preferences.kt b/domain/src/main/java/com/moez/QKSMS/util/Preferences.kt index b77b42542..57cacf62a 100644 --- a/domain/src/main/java/com/moez/QKSMS/util/Preferences.kt +++ b/domain/src/main/java/com/moez/QKSMS/util/Preferences.kt @@ -47,6 +47,7 @@ class Preferences @Inject constructor( const val TEXT_SIZE_NORMAL = 1 const val TEXT_SIZE_LARGE = 2 const val TEXT_SIZE_LARGER = 3 + const val TEXT_SIZE_SUPER = 4 const val NOTIFICATION_PREVIEWS_ALL = 0 const val NOTIFICATION_PREVIEWS_NAME = 1 diff --git a/presentation/src/main/java/com/moez/QKSMS/common/util/TextViewStyler.kt b/presentation/src/main/java/com/moez/QKSMS/common/util/TextViewStyler.kt index 4b7f90d3c..9e4d110d1 100644 --- a/presentation/src/main/java/com/moez/QKSMS/common/util/TextViewStyler.kt +++ b/presentation/src/main/java/com/moez/QKSMS/common/util/TextViewStyler.kt @@ -153,6 +153,7 @@ class TextViewStyler @Inject constructor( Preferences.TEXT_SIZE_NORMAL -> 16f Preferences.TEXT_SIZE_LARGE -> 18f Preferences.TEXT_SIZE_LARGER -> 20f + Preferences.TEXT_SIZE_SUPER -> 40f else -> 16f } @@ -161,6 +162,7 @@ class TextViewStyler @Inject constructor( Preferences.TEXT_SIZE_NORMAL -> 14f Preferences.TEXT_SIZE_LARGE -> 16f Preferences.TEXT_SIZE_LARGER -> 18f + Preferences.TEXT_SIZE_SUPER -> 36f else -> 14f } @@ -169,6 +171,7 @@ class TextViewStyler @Inject constructor( Preferences.TEXT_SIZE_NORMAL -> 12f Preferences.TEXT_SIZE_LARGE -> 14f Preferences.TEXT_SIZE_LARGER -> 16f + Preferences.TEXT_SIZE_SUPER -> 32f else -> 12f } @@ -177,6 +180,7 @@ class TextViewStyler @Inject constructor( Preferences.TEXT_SIZE_NORMAL -> 20f Preferences.TEXT_SIZE_LARGE -> 22f Preferences.TEXT_SIZE_LARGER -> 26f + Preferences.TEXT_SIZE_SUPER -> 52f else -> 20f } @@ -185,6 +189,7 @@ class TextViewStyler @Inject constructor( Preferences.TEXT_SIZE_NORMAL -> 18f Preferences.TEXT_SIZE_LARGE -> 20f Preferences.TEXT_SIZE_LARGER -> 24f + Preferences.TEXT_SIZE_SUPER -> 48f else -> 18f } @@ -193,6 +198,7 @@ class TextViewStyler @Inject constructor( Preferences.TEXT_SIZE_NORMAL -> 32f Preferences.TEXT_SIZE_LARGE -> 36f Preferences.TEXT_SIZE_LARGER -> 40f + Preferences.TEXT_SIZE_SUPER -> 80f else -> 32f } } diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml index 9fa476351..d6d00b45c 100644 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -450,6 +450,7 @@ Normal Large Larger + Super From 6eacfe46102140b507905ce687e00d28a4b68410 Mon Sep 17 00:00:00 2001 From: gavine99 Date: Sun, 5 Jan 2025 20:33:05 +1100 Subject: [PATCH 2/5] added feature to speak messages aloud via system TTS service. added "Speak" option to message swipe left/right options. swipe inbox message to read aloud. swipe again to interrupt. added widget that speaks unseen messages and marks messages as seen after reading them. needs string translations for settings_swipe_actions (new item "Speak") and speak_no_messages and speak_unseen_messages --- .../QKSMS/receiver/SpeakThreadsReceiver.kt | 50 +++++++++++++ .../repository/ConversationRepositoryImpl.kt | 68 ++++++++++++++++- .../repository/ConversationRepository.kt | 6 ++ .../java/com/moez/QKSMS/util/Preferences.kt | 2 +- presentation/src/main/AndroidManifest.xml | 23 ++++++ .../com/moez/QKSMS/common/QKApplication.kt | 8 ++ .../moez/QKSMS/common/util/TextViewStyler.kt | 6 -- .../ConversationItemTouchCallback.kt | 1 + .../moez/QKSMS/feature/main/MainViewModel.kt | 3 + .../settings/swipe/SwipeActionsPresenter.kt | 1 + .../widget/WidgetSpeakUnseenProvider.kt | 69 ++++++++++++++++++ .../android/BroadcastReceiverBuilderModule.kt | 5 ++ .../ic_speak_unseen_widget.png | Bin 0 -> 18141 bytes .../res/drawable/ic_speaker_black_24dp.xml | 9 +++ .../main/res/layout/widget_speak_unseen.xml | 36 +++++++++ presentation/src/main/res/values/strings.xml | 5 +- .../main/res/xml/widget_speak_unseen_info.xml | 27 +++++++ 17 files changed, 306 insertions(+), 13 deletions(-) create mode 100644 data/src/main/java/com/moez/QKSMS/receiver/SpeakThreadsReceiver.kt create mode 100644 presentation/src/main/java/com/moez/QKSMS/feature/widget/WidgetSpeakUnseenProvider.kt create mode 100644 presentation/src/main/res/drawable-xxxhdpi/ic_speak_unseen_widget.png create mode 100644 presentation/src/main/res/drawable/ic_speaker_black_24dp.xml create mode 100644 presentation/src/main/res/layout/widget_speak_unseen.xml create mode 100644 presentation/src/main/res/xml/widget_speak_unseen_info.xml diff --git a/data/src/main/java/com/moez/QKSMS/receiver/SpeakThreadsReceiver.kt b/data/src/main/java/com/moez/QKSMS/receiver/SpeakThreadsReceiver.kt new file mode 100644 index 000000000..d51fec09f --- /dev/null +++ b/data/src/main/java/com/moez/QKSMS/receiver/SpeakThreadsReceiver.kt @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2017 Moez Bhatti + * + * This file is part of QKSMS. + * + * QKSMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * QKSMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with QKSMS. If not, see . + */ +package dev.octoshrimpy.quik.receiver + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import dev.octoshrimpy.quik.interactor.SpeakThreads +import dagger.android.AndroidInjection +import dev.octoshrimpy.quik.repository.ConversationRepository +import javax.inject.Inject + +class SpeakThreadsReceiver : BroadcastReceiver() { + + @Inject lateinit var speakThread: SpeakThreads + @Inject lateinit var conversationRepo: ConversationRepository + + + override fun onReceive(context: Context, intent: Intent) { + AndroidInjection.inject(this, context) + + val pendingResult = goAsync() + val threadId = intent.getLongExtra("threadId", 0) + + val threads = when { + (threadId == -1L) -> conversationRepo.getUnseenIds() + (threadId == -2L) -> conversationRepo.getUnreadIds() + else -> listOf(threadId) + } + + speakThread.execute(threads) { pendingResult.finish() } + } + +} \ No newline at end of file diff --git a/data/src/main/java/com/moez/QKSMS/repository/ConversationRepositoryImpl.kt b/data/src/main/java/com/moez/QKSMS/repository/ConversationRepositoryImpl.kt index 634b9868d..9ef8d2449 100644 --- a/data/src/main/java/com/moez/QKSMS/repository/ConversationRepositoryImpl.kt +++ b/data/src/main/java/com/moez/QKSMS/repository/ConversationRepositoryImpl.kt @@ -21,6 +21,7 @@ package dev.octoshrimpy.quik.repository import android.content.ContentUris import android.content.Context import android.provider.Telephony +import android.view.View.OnLongClickListener import dev.octoshrimpy.quik.compat.TelephonyCompat import dev.octoshrimpy.quik.extensions.anyOf import dev.octoshrimpy.quik.extensions.asObservable @@ -201,10 +202,69 @@ class ConversationRepositoryImpl @Inject constructor( override fun getConversation(threadId: Long): Conversation? { return Realm.getDefaultInstance() - .apply { refresh() } - .where(Conversation::class.java) - .equalTo("id", threadId) - .findFirst() + .apply { refresh() } + .where(Conversation::class.java) + .equalTo("id", threadId) + .findFirst() + } + + override fun getUnseenIds(archived: Boolean): List { + val conversationIds = ArrayList() + + Realm.getDefaultInstance() + .where(Conversation::class.java) + .notEqualTo("id", 0L) + .equalTo("archived", archived) + .equalTo("blocked", false) + .equalTo("lastMessage.seen", false) + .sort( + arrayOf("lastMessage.date"), + arrayOf(Sort.DESCENDING) + ) + .findAllAsync() + .forEach { conversation -> conversationIds.add(conversation.id) } + + return conversationIds + } + + override fun getUnreadIds(archived: Boolean): List { + val conversationIds = ArrayList() + + Realm.getDefaultInstance() + .where(Conversation::class.java) + .notEqualTo("id", 0L) + .equalTo("archived", archived) + .equalTo("blocked", false) + .equalTo("lastMessage.read", false) + .sort( + arrayOf("lastMessage.date"), + arrayOf(Sort.DESCENDING) + ) + .findAllAsync() + .forEach { conversation -> conversationIds.add(conversation.id) } + + return conversationIds + } + + override fun getConversationAndLastSenderContactName(threadId: Long): Pair? { + val conversation = Realm.getDefaultInstance() + .apply { refresh() } + .where(Conversation::class.java) + .equalTo("id", threadId) + .findFirst() + + if (conversation === null) + return null + + var conversationLastSmsSender: String? = null + + if (conversation !== null) { + conversationLastSmsSender = conversation?.recipients?.find { recipient -> + phoneNumberUtils.compare(recipient.address, conversation.lastMessage!!.address) + }?.contact?.name + } + + return return Pair(conversation, conversationLastSmsSender) } override fun getConversations(vararg threadIds: Long): RealmResults { diff --git a/domain/src/main/java/com/moez/QKSMS/repository/ConversationRepository.kt b/domain/src/main/java/com/moez/QKSMS/repository/ConversationRepository.kt index 57d1a1779..77cf4130f 100644 --- a/domain/src/main/java/com/moez/QKSMS/repository/ConversationRepository.kt +++ b/domain/src/main/java/com/moez/QKSMS/repository/ConversationRepository.kt @@ -48,6 +48,12 @@ interface ConversationRepository { fun getConversation(threadId: Long): Conversation? + fun getUnseenIds(archived: Boolean = false): List + + fun getUnreadIds(archived: Boolean = false): List + + fun getConversationAndLastSenderContactName(threadId: Long): Pair? + /** * Returns all conversations with an id in [threadIds] */ diff --git a/domain/src/main/java/com/moez/QKSMS/util/Preferences.kt b/domain/src/main/java/com/moez/QKSMS/util/Preferences.kt index 57cacf62a..aa9af06a7 100644 --- a/domain/src/main/java/com/moez/QKSMS/util/Preferences.kt +++ b/domain/src/main/java/com/moez/QKSMS/util/Preferences.kt @@ -47,7 +47,6 @@ class Preferences @Inject constructor( const val TEXT_SIZE_NORMAL = 1 const val TEXT_SIZE_LARGE = 2 const val TEXT_SIZE_LARGER = 3 - const val TEXT_SIZE_SUPER = 4 const val NOTIFICATION_PREVIEWS_ALL = 0 const val NOTIFICATION_PREVIEWS_NAME = 1 @@ -73,6 +72,7 @@ class Preferences @Inject constructor( const val SWIPE_ACTION_CALL = 4 const val SWIPE_ACTION_READ = 5 const val SWIPE_ACTION_UNREAD = 6 + const val SWIPE_ACTION_SPEAK = 7 const val BLOCKING_MANAGER_QKSMS = 0 const val BLOCKING_MANAGER_CC = 1 diff --git a/presentation/src/main/AndroidManifest.xml b/presentation/src/main/AndroidManifest.xml index 7535d4207..af4adfdb5 100644 --- a/presentation/src/main/AndroidManifest.xml +++ b/presentation/src/main/AndroidManifest.xml @@ -175,6 +175,13 @@ + + + + + + + + + + + + + @@ -259,6 +279,9 @@ + + + diff --git a/presentation/src/main/java/com/moez/QKSMS/common/QKApplication.kt b/presentation/src/main/java/com/moez/QKSMS/common/QKApplication.kt index 29e1af7c7..5ba9645a1 100644 --- a/presentation/src/main/java/com/moez/QKSMS/common/QKApplication.kt +++ b/presentation/src/main/java/com/moez/QKSMS/common/QKApplication.kt @@ -25,6 +25,7 @@ import android.content.BroadcastReceiver import androidx.core.provider.FontRequest import androidx.emoji.text.EmojiCompat import androidx.emoji.text.FontRequestEmojiCompatConfig +import com.moez.QKSMS.manager.SpeakManager import dev.octoshrimpy.quik.R import dev.octoshrimpy.quik.common.util.CrashlyticsTree import dev.octoshrimpy.quik.common.util.FileLoggingTree @@ -43,6 +44,7 @@ import dagger.android.DispatchingAndroidInjector import dagger.android.HasActivityInjector import dagger.android.HasBroadcastReceiverInjector import dagger.android.HasServiceInjector +import dev.octoshrimpy.quik.interactor.SpeakThreads import io.realm.Realm import io.realm.RealmConfiguration import kotlinx.coroutines.Dispatchers @@ -73,6 +75,12 @@ class QKApplication : Application(), HasActivityInjector, HasBroadcastReceiverIn override fun onCreate() { super.onCreate() + // set application context for SpeakManager + SpeakManager.setContext(this) + + // set translated "no messages" string for speakThreads interactor + SpeakThreads.setNoMessagesString(getString(R.string.speak_no_messages)) + AppComponentManager.init(this) appComponent.inject(this) diff --git a/presentation/src/main/java/com/moez/QKSMS/common/util/TextViewStyler.kt b/presentation/src/main/java/com/moez/QKSMS/common/util/TextViewStyler.kt index 9e4d110d1..4b7f90d3c 100644 --- a/presentation/src/main/java/com/moez/QKSMS/common/util/TextViewStyler.kt +++ b/presentation/src/main/java/com/moez/QKSMS/common/util/TextViewStyler.kt @@ -153,7 +153,6 @@ class TextViewStyler @Inject constructor( Preferences.TEXT_SIZE_NORMAL -> 16f Preferences.TEXT_SIZE_LARGE -> 18f Preferences.TEXT_SIZE_LARGER -> 20f - Preferences.TEXT_SIZE_SUPER -> 40f else -> 16f } @@ -162,7 +161,6 @@ class TextViewStyler @Inject constructor( Preferences.TEXT_SIZE_NORMAL -> 14f Preferences.TEXT_SIZE_LARGE -> 16f Preferences.TEXT_SIZE_LARGER -> 18f - Preferences.TEXT_SIZE_SUPER -> 36f else -> 14f } @@ -171,7 +169,6 @@ class TextViewStyler @Inject constructor( Preferences.TEXT_SIZE_NORMAL -> 12f Preferences.TEXT_SIZE_LARGE -> 14f Preferences.TEXT_SIZE_LARGER -> 16f - Preferences.TEXT_SIZE_SUPER -> 32f else -> 12f } @@ -180,7 +177,6 @@ class TextViewStyler @Inject constructor( Preferences.TEXT_SIZE_NORMAL -> 20f Preferences.TEXT_SIZE_LARGE -> 22f Preferences.TEXT_SIZE_LARGER -> 26f - Preferences.TEXT_SIZE_SUPER -> 52f else -> 20f } @@ -189,7 +185,6 @@ class TextViewStyler @Inject constructor( Preferences.TEXT_SIZE_NORMAL -> 18f Preferences.TEXT_SIZE_LARGE -> 20f Preferences.TEXT_SIZE_LARGER -> 24f - Preferences.TEXT_SIZE_SUPER -> 48f else -> 18f } @@ -198,7 +193,6 @@ class TextViewStyler @Inject constructor( Preferences.TEXT_SIZE_NORMAL -> 32f Preferences.TEXT_SIZE_LARGE -> 36f Preferences.TEXT_SIZE_LARGER -> 40f - Preferences.TEXT_SIZE_SUPER -> 80f else -> 32f } } diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/conversations/ConversationItemTouchCallback.kt b/presentation/src/main/java/com/moez/QKSMS/feature/conversations/ConversationItemTouchCallback.kt index 048e25c48..e32320b78 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/conversations/ConversationItemTouchCallback.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/conversations/ConversationItemTouchCallback.kt @@ -153,6 +153,7 @@ class ConversationItemTouchCallback @Inject constructor( Preferences.SWIPE_ACTION_CALL -> R.drawable.ic_call_white_24dp Preferences.SWIPE_ACTION_READ -> R.drawable.ic_check_white_24dp Preferences.SWIPE_ACTION_UNREAD -> R.drawable.ic_markunread_black_24dp + Preferences.SWIPE_ACTION_SPEAK -> R.drawable.ic_speaker_black_24dp else -> null } diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/main/MainViewModel.kt b/presentation/src/main/java/com/moez/QKSMS/feature/main/MainViewModel.kt index 68e42debb..03439ef71 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/main/MainViewModel.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/main/MainViewModel.kt @@ -48,6 +48,7 @@ import dev.octoshrimpy.quik.repository.SyncRepository import dev.octoshrimpy.quik.util.Preferences import com.uber.autodispose.android.lifecycle.scope import com.uber.autodispose.autoDisposable +import dev.octoshrimpy.quik.interactor.SpeakThreads import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.rxkotlin.plusAssign import io.reactivex.rxkotlin.withLatestFrom @@ -75,6 +76,7 @@ class MainViewModel @Inject constructor( private val markUnarchived: MarkUnarchived, private val markUnpinned: MarkUnpinned, private val markUnread: MarkUnread, + private val speakThreads: SpeakThreads, private val navigator: Navigator, private val permissionManager: PermissionManager, private val prefs: Preferences, @@ -466,6 +468,7 @@ class MainViewModel @Inject constructor( Preferences.SWIPE_ACTION_CALL -> conversationRepo.getConversation(threadId)?.recipients?.firstOrNull()?.address?.let(navigator::makePhoneCall) Preferences.SWIPE_ACTION_READ -> markRead.execute(listOf(threadId)) Preferences.SWIPE_ACTION_UNREAD -> markUnread.execute(listOf(threadId)) + Preferences.SWIPE_ACTION_SPEAK -> speakThreads.execute(listOf(threadId)) } } diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/settings/swipe/SwipeActionsPresenter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/settings/swipe/SwipeActionsPresenter.kt index 5dad27b7b..704c351a7 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/settings/swipe/SwipeActionsPresenter.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/settings/swipe/SwipeActionsPresenter.kt @@ -76,6 +76,7 @@ class SwipeActionsPresenter @Inject constructor( Preferences.SWIPE_ACTION_CALL -> R.drawable.ic_call_white_24dp Preferences.SWIPE_ACTION_READ -> R.drawable.ic_check_white_24dp Preferences.SWIPE_ACTION_UNREAD -> R.drawable.ic_markunread_black_24dp + Preferences.SWIPE_ACTION_SPEAK -> R.drawable.ic_speaker_black_24dp else -> 0 } diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/widget/WidgetSpeakUnseenProvider.kt b/presentation/src/main/java/com/moez/QKSMS/feature/widget/WidgetSpeakUnseenProvider.kt new file mode 100644 index 000000000..94c75a675 --- /dev/null +++ b/presentation/src/main/java/com/moez/QKSMS/feature/widget/WidgetSpeakUnseenProvider.kt @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2017 Moez Bhatti + * + * This file is part of QKSMS. + * + * QKSMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * QKSMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with QKSMS. If not, see . + */ + +package dev.octoshrimpy.quik.feature.widget + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import android.content.Intent +import android.widget.ImageView +import android.widget.RemoteViews +import dev.octoshrimpy.quik.R +import dev.octoshrimpy.quik.common.util.extensions.getColorCompat + +class WidgetSpeakUnseenProvider : AppWidgetProvider() { + + override fun onReceive(context: Context, intent: Intent) { + super.onReceive(context, intent) + } + + override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { + super.onUpdate(context, appWidgetManager, appWidgetIds) + + for (appWidgetId in appWidgetIds) + updateWidget(context, appWidgetId) + } + + fun updateWidget(context: Context?, appWidgetId: Int) { + super.onEnabled(context) + + if (context == null) + return + + val remoteViews = RemoteViews(context.packageName, R.layout.widget_speak_unseen) + + remoteViews.setImageViewResource(R.id.speakUnseenImage, R.drawable.ic_speak_unseen_widget) + + // speak unseen intent + val speakUnseenIntent = Intent("dev.octoshrimpy.quik.intent.action.ACTION_SPEAK_MESSAGES") + .setPackage(context.packageName) + .putExtra("threadId", -1L) + val speakUnseenPendingIntent = PendingIntent.getBroadcast( + context, + 0, + speakUnseenIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + remoteViews.setOnClickPendingIntent(R.id.speakUnseenImage, speakUnseenPendingIntent) + + AppWidgetManager.getInstance(context).updateAppWidget(appWidgetId, remoteViews) + } + +} diff --git a/presentation/src/main/java/com/moez/QKSMS/injection/android/BroadcastReceiverBuilderModule.kt b/presentation/src/main/java/com/moez/QKSMS/injection/android/BroadcastReceiverBuilderModule.kt index f2066a06b..3c48da3e0 100644 --- a/presentation/src/main/java/com/moez/QKSMS/injection/android/BroadcastReceiverBuilderModule.kt +++ b/presentation/src/main/java/com/moez/QKSMS/injection/android/BroadcastReceiverBuilderModule.kt @@ -40,6 +40,7 @@ import dev.octoshrimpy.quik.receiver.SmsReceiver import dev.octoshrimpy.quik.receiver.SmsSentReceiver import dagger.Module import dagger.android.ContributesAndroidInjector +import dev.octoshrimpy.quik.receiver.SpeakThreadsReceiver @Module abstract class BroadcastReceiverBuilderModule { @@ -68,6 +69,10 @@ abstract class BroadcastReceiverBuilderModule { @ContributesAndroidInjector() abstract fun bindMarkReadReceiver(): MarkReadReceiver + @ActivityScope + @ContributesAndroidInjector() + abstract fun bindSpeakThreadsReceiver(): SpeakThreadsReceiver + @ActivityScope @ContributesAndroidInjector() abstract fun bindMarkSeenReceiver(): MarkSeenReceiver diff --git a/presentation/src/main/res/drawable-xxxhdpi/ic_speak_unseen_widget.png b/presentation/src/main/res/drawable-xxxhdpi/ic_speak_unseen_widget.png new file mode 100644 index 0000000000000000000000000000000000000000..b472719c98a2329d356ab6bbc5e526a09d71fb8e GIT binary patch literal 18141 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 literal 0 HcmV?d00001 diff --git a/presentation/src/main/res/drawable/ic_speaker_black_24dp.xml b/presentation/src/main/res/drawable/ic_speaker_black_24dp.xml new file mode 100644 index 000000000..6797d19b3 --- /dev/null +++ b/presentation/src/main/res/drawable/ic_speaker_black_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/presentation/src/main/res/layout/widget_speak_unseen.xml b/presentation/src/main/res/layout/widget_speak_unseen.xml new file mode 100644 index 000000000..34be424f1 --- /dev/null +++ b/presentation/src/main/res/layout/widget_speak_unseen.xml @@ -0,0 +1,36 @@ + + + + + + diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml index d6d00b45c..a4d353f2d 100644 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -255,6 +255,7 @@ Call Mark read Mark unread + Speak Delivery confirmations Confirm that messages were sent successfully @@ -450,7 +451,6 @@ Normal Large Larger - Super @@ -496,5 +496,6 @@ LOL That\'s okay - + No Messages + Speak unseen messages diff --git a/presentation/src/main/res/xml/widget_speak_unseen_info.xml b/presentation/src/main/res/xml/widget_speak_unseen_info.xml new file mode 100644 index 000000000..507401488 --- /dev/null +++ b/presentation/src/main/res/xml/widget_speak_unseen_info.xml @@ -0,0 +1,27 @@ + + + \ No newline at end of file From 9ae36b2c0be59e512c784cfbb404c120dd41602a Mon Sep 17 00:00:00 2001 From: gavine99 Date: Sun, 5 Jan 2025 20:51:51 +1100 Subject: [PATCH 3/5] added a couple of files that weren't added in last commit --- .../com/moez/QKSMS/interactor/SpeakThreads.kt | 64 ++++ .../com/moez/QKSMS/manager/SpeakManager.kt | 284 ++++++++++++++++++ 2 files changed, 348 insertions(+) create mode 100644 domain/src/main/java/com/moez/QKSMS/interactor/SpeakThreads.kt create mode 100644 domain/src/main/java/com/moez/QKSMS/manager/SpeakManager.kt diff --git a/domain/src/main/java/com/moez/QKSMS/interactor/SpeakThreads.kt b/domain/src/main/java/com/moez/QKSMS/interactor/SpeakThreads.kt new file mode 100644 index 000000000..900d5a0ba --- /dev/null +++ b/domain/src/main/java/com/moez/QKSMS/interactor/SpeakThreads.kt @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2017 Moez Bhatti + * + * This file is part of QKSMS. + * + * QKSMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * QKSMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with QKSMS. If not, see . + */ +package dev.octoshrimpy.quik.interactor + +import android.content.res.Resources +import com.moez.QKSMS.manager.SpeakManager +import dev.octoshrimpy.quik.domain.R +import dev.octoshrimpy.quik.extensions.mapNotNull +import dev.octoshrimpy.quik.repository.ConversationRepository +import dev.octoshrimpy.quik.repository.MessageRepository +import io.reactivex.Flowable +import io.reactivex.Single +import javax.inject.Inject + +class SpeakThreads @Inject constructor( + private val conversationRepo: ConversationRepository, + private val messageRepo: MessageRepository, +) : Interactor>() { + + companion object { + private var noMessagesStr = "No messages" // default value + + fun setNoMessagesString(newNoMessagesString: String) { + noMessagesStr = newNoMessagesString + } + } + + override fun buildObservable(threadIds: List): Flowable<*> { + val speakManager = SpeakManager() + + if (threadIds.isEmpty()) + return Single.just(0) + .doOnSubscribe { speakManager.startSpeakSession(noMessagesStr) } + .map { speakManager.speak(noMessagesStr) } + .doOnTerminate { speakManager.endSpeakSession() } + .toFlowable() + + return Flowable.fromIterable(threadIds) + .doOnSubscribe { speakManager.startSpeakSession("threads:" + threadIds.sorted().joinToString()) } + .mapNotNull { threadId -> conversationRepo.getConversationAndLastSenderContactName(threadId) } + .map { conversationAndSender -> + if (speakManager.speakConversationLastSms(conversationAndSender)) + messageRepo.markSeen(conversationAndSender.first!!.id) + } + .doOnTerminate { speakManager.endSpeakSession() } + } + +} \ No newline at end of file diff --git a/domain/src/main/java/com/moez/QKSMS/manager/SpeakManager.kt b/domain/src/main/java/com/moez/QKSMS/manager/SpeakManager.kt new file mode 100644 index 000000000..adac8728f --- /dev/null +++ b/domain/src/main/java/com/moez/QKSMS/manager/SpeakManager.kt @@ -0,0 +1,284 @@ +package com.moez.QKSMS.manager + +import android.content.Context +import android.media.AudioAttributes +import android.media.AudioFocusRequest +import android.media.AudioManager +import android.os.Build +import android.speech.tts.TextToSpeech +import android.speech.tts.UtteranceProgressListener +import android.text.format.DateFormat +import dev.octoshrimpy.quik.model.Conversation +import kotlinx.coroutines.runBlocking +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale +import java.util.concurrent.atomic.AtomicReference +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + + +class SpeakManager() { + companion object { + // system TextToSpeech engine + private var staticTextToSpeech = AtomicReference(null) + + val lock = Any() + + private var context: Context? = null + + // currently speaking sessionId + private var currentSessionId: String? = null + + // audio manager items for audio focus + private var audioManager: AudioManager? = null + private var audioFocusRequest: AudioFocusRequest? = null + + fun setContext(inContext: Context) { + synchronized(lock) { + if (context !== null) + return + context = inContext + } + } + } + + private var sessionId: String? = null + private var sessionStopped = false + + + private fun getSystemTtsEngine(): TextToSpeech? { + // if system TextToSpeech already assigned + var tts = staticTextToSpeech.get() + if (tts !== null) + return tts + + // if context not set then the global engine can not be instantiated + if (context === null) + return null + + val audioAttributes = AudioAttributes.Builder(). run { + setUsage(AudioAttributes.USAGE_ASSISTANT) + setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + build() + } + + var localAudioManager: AudioManager? = null + var localAudioFocusRequest: AudioFocusRequest? = null + + synchronized(lock) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // init audio manager and audio focus request first time through only + localAudioManager = + context?.getSystemService(Context.AUDIO_SERVICE) as AudioManager + if (localAudioManager === null) + return null + + localAudioFocusRequest = + AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT) + .run { + setAudioAttributes(audioAttributes) + setAcceptsDelayedFocusGain(false) + build() + } + if (localAudioFocusRequest === null) + return null + } + + // blocking to allow waiting for tts engine response in a synchronous manner + runBlocking { + // get system tts engine + val ttsResult = suspendCoroutine { continuation -> + tts = TextToSpeech(context) { status -> + if (status == TextToSpeech.ERROR) { + tts?.shutdown() // release resources in case of failure to start + continuation.resume(false) + } else + continuation.resume(true) + } + } + + // if error reported in onInitListener.onInit from system TextToSpeech Engine + if (!ttsResult) + tts = null + } + + // system TextToSpeech not able to be initialised, return failure + if (tts == null) + return null + + // old-school setting of audio attribute. > Build.VERSION_CODES.O uses audio focus + tts.setAudioAttributes(audioAttributes) + + // handlers for start, done and error utterance events + tts.setOnUtteranceProgressListener(object : + UtteranceProgressListener() { + override fun onStart(utteranceId: String) { + currentSessionId = utteranceId + + // request audio focus so other audio can be ducked/paused + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + audioManager?.requestAudioFocus(audioFocusRequest!!) + } + + override fun onDone(utteranceId: String) { + currentSessionId = null + + // abandon audio focus + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + audioManager?.abandonAudioFocusRequest(audioFocusRequest!!) + } + + override fun onError(utteranceId: String) { + currentSessionId = null + + // abandon audio focus + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + audioManager?.abandonAudioFocusRequest(audioFocusRequest!!) + } + }) + + // assign all these local vars to statics at once so if staticTextToSpeech is set then we know all are set + staticTextToSpeech.set(tts) + audioManager = localAudioManager + audioFocusRequest = localAudioFocusRequest + } + + return tts + } + + private fun formatDateForSpeech(date: Long): String { + fun getFormatter(pattern: String): SimpleDateFormat { + var formattedPattern = DateFormat.getBestDateTimePattern(Locale.getDefault(), pattern) + + if (DateFormat.is24HourFormat(context)) { + formattedPattern = formattedPattern + .replace("h", "HH") + .replace("K", "HH") + .replace("\\s+a".toRegex(), "") + } + + return SimpleDateFormat(formattedPattern, Locale.getDefault()) + } + + val now = Calendar.getInstance() + val then = Calendar.getInstance() + val yesterdayCheck = Calendar.getInstance() + val lastWeekCheck = Calendar.getInstance() + + then.timeInMillis = date + yesterdayCheck.timeInMillis = date + yesterdayCheck.add(Calendar.DATE, 1) + lastWeekCheck.timeInMillis = date + lastWeekCheck.add(Calendar.DATE, 7) + + return when { + (then.get(Calendar.DATE) == now.get(Calendar.DATE)) -> StringBuilder() // today + (yesterdayCheck.get(Calendar.DATE) == now.get(Calendar.DATE)) -> StringBuilder("yesterday") // yesterday + (lastWeekCheck.get(Calendar.DATE) > now.get(Calendar.DATE)) -> StringBuilder("on ").append(getFormatter("EEEE").format(date)) // during last week + (then.get(Calendar.YEAR) == now.get(Calendar.YEAR)) -> StringBuilder("on ").append(getFormatter("MMMM d").format(date)) // this year + else -> StringBuilder("on").append(getFormatter("MMMM d yyyy").format(date)) // otherwise + }.append(" at ").append(getFormatter("h:mm a").format(date)).toString() + } + + // toggle is used to stop and not repeat if a sessionId of the same name is currently being read aloud + fun startSpeakSession(sessionId: String? = null, toggle: Boolean = true) { + // get or init system TextToSpeech engine + val tts = getSystemTtsEngine() + if (tts === null) + return + + this.sessionId = sessionId + + sessionStopped = if ((this.sessionId !== null) && (toggle) && (currentSessionId == sessionId)) { + true // do not output any speech in this session + } else + false + + // stop any current speech immediately + stopSpeaking() + } + + fun endSpeakSession() { + sessionStopped = true + } + + fun stopSpeaking() { + // get or init system TextToSpeech engine + val tts = getSystemTtsEngine() + if (tts === null) + return + + currentSessionId = null // no utteranceprogresslistener callback so set this explicitly here + tts.stop() + + // abandon audio focus + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + audioManager?.abandonAudioFocusRequest(audioFocusRequest!!) + } + + fun speak(utterance: String) { + // if session stopped, return now + if (sessionStopped) + return + + // get or init system TextToSpeech engine + val tts = getSystemTtsEngine() + if (tts === null) + return + + tts.speak( + utterance, + TextToSpeech.QUEUE_ADD, + null, + sessionId + ) + } + + fun speakConversationLastSms(conversationAndSender: Pair): Boolean { + // if session stopped, return now + if (sessionStopped) + return false + + // get or init system TextToSpeech engine + val tts = getSystemTtsEngine() + if (tts === null) + return false + + // no conversation?, fail + val conversation = conversationAndSender.first + if (conversation === null) + return false + + // no text in conversation's last message?, ditch + val conversationLastSms = conversation.lastMessage + val conversationLastSmsText = conversationLastSms?.getText() + if (conversationLastSmsText === null) + return false + + val utterance = StringBuilder() + + // more than 1 recipient + if (conversation.recipients.count() > 1) + utterance.append("Group SMS ") + + // message sender + if (conversationLastSms.isMe()) + utterance.append("Sent by you to ") + else + utterance.append("from ") + + utterance.append(conversationAndSender.second ?: "an unknown number ") + + // message date + if (conversation.date > 0) + utterance.append(formatDateForSpeech(conversationLastSms.date)) + + // small delay + utterance.append(". ").append(conversationLastSmsText) + + speak(utterance.toString()) + + return true + } +} \ No newline at end of file From 20f2e7ff0e25abb32dc5281d68706295af135b7b Mon Sep 17 00:00:00 2001 From: gavine99 Date: Tue, 7 Jan 2025 13:32:09 +1100 Subject: [PATCH 4/5] Added "speak" option for new message notification buttons --- domain/src/main/java/com/moez/QKSMS/util/Preferences.kt | 1 + .../moez/QKSMS/common/util/NotificationManagerImpl.kt | 9 +++++++++ presentation/src/main/res/values/strings.xml | 1 + 3 files changed, 11 insertions(+) diff --git a/domain/src/main/java/com/moez/QKSMS/util/Preferences.kt b/domain/src/main/java/com/moez/QKSMS/util/Preferences.kt index aa9af06a7..23607bbbc 100644 --- a/domain/src/main/java/com/moez/QKSMS/util/Preferences.kt +++ b/domain/src/main/java/com/moez/QKSMS/util/Preferences.kt @@ -59,6 +59,7 @@ class Preferences @Inject constructor( const val NOTIFICATION_ACTION_CALL = 4 const val NOTIFICATION_ACTION_READ = 5 const val NOTIFICATION_ACTION_REPLY = 6 + const val NOTIFICATION_ACTION_SPEAK = 7 const val SEND_DELAY_NONE = 0 const val SEND_DELAY_SHORT = 1 diff --git a/presentation/src/main/java/com/moez/QKSMS/common/util/NotificationManagerImpl.kt b/presentation/src/main/java/com/moez/QKSMS/common/util/NotificationManagerImpl.kt index 657aa30b9..f3fee9f26 100644 --- a/presentation/src/main/java/com/moez/QKSMS/common/util/NotificationManagerImpl.kt +++ b/presentation/src/main/java/com/moez/QKSMS/common/util/NotificationManagerImpl.kt @@ -51,6 +51,7 @@ import dev.octoshrimpy.quik.receiver.MarkArchivedReceiver import dev.octoshrimpy.quik.receiver.MarkReadReceiver import dev.octoshrimpy.quik.receiver.MarkSeenReceiver import dev.octoshrimpy.quik.receiver.RemoteMessagingReceiver +import dev.octoshrimpy.quik.receiver.SpeakThreadsReceiver import dev.octoshrimpy.quik.repository.ConversationRepository import dev.octoshrimpy.quik.repository.MessageRepository import dev.octoshrimpy.quik.util.GlideApp @@ -294,6 +295,14 @@ class NotificationManagerImpl @Inject constructor( .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_CALL).build() } + Preferences.NOTIFICATION_ACTION_SPEAK -> { + val intent = Intent(context, SpeakThreadsReceiver::class.java).putExtra("threadId", threadId) + val pi = PendingIntent.getBroadcast(context, 0, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + NotificationCompat.Action.Builder(R.drawable.ic_speaker_black_24dp, actionLabels[action], pi) + .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_NONE).build() + } + else -> null } } diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml index a4d353f2d..8484729ab 100644 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -410,6 +410,7 @@ Call Mark read Reply + Speak Yes From 714cec274b1885f3900de370255f22a9f924917c Mon Sep 17 00:00:00 2001 From: gavine99 Date: Tue, 7 Jan 2025 16:21:48 +1100 Subject: [PATCH 5/5] use class instead of hard coded string in speak unseen msgs widget. small code cleanups in the same file --- .../feature/widget/WidgetSpeakUnseenProvider.kt | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/widget/WidgetSpeakUnseenProvider.kt b/presentation/src/main/java/com/moez/QKSMS/feature/widget/WidgetSpeakUnseenProvider.kt index 94c75a675..c68baac3e 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/widget/WidgetSpeakUnseenProvider.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/widget/WidgetSpeakUnseenProvider.kt @@ -24,10 +24,9 @@ import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetProvider import android.content.Context import android.content.Intent -import android.widget.ImageView import android.widget.RemoteViews import dev.octoshrimpy.quik.R -import dev.octoshrimpy.quik.common.util.extensions.getColorCompat +import dev.octoshrimpy.quik.receiver.SpeakThreadsReceiver class WidgetSpeakUnseenProvider : AppWidgetProvider() { @@ -42,7 +41,7 @@ class WidgetSpeakUnseenProvider : AppWidgetProvider() { updateWidget(context, appWidgetId) } - fun updateWidget(context: Context?, appWidgetId: Int) { + private fun updateWidget(context: Context?, appWidgetId: Int) { super.onEnabled(context) if (context == null) @@ -53,14 +52,10 @@ class WidgetSpeakUnseenProvider : AppWidgetProvider() { remoteViews.setImageViewResource(R.id.speakUnseenImage, R.drawable.ic_speak_unseen_widget) // speak unseen intent - val speakUnseenIntent = Intent("dev.octoshrimpy.quik.intent.action.ACTION_SPEAK_MESSAGES") - .setPackage(context.packageName) + val speakUnseenIntent = Intent(context, SpeakThreadsReceiver::class.java) .putExtra("threadId", -1L) - val speakUnseenPendingIntent = PendingIntent.getBroadcast( - context, - 0, - speakUnseenIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + val speakUnseenPendingIntent = PendingIntent.getBroadcast(context,0, + speakUnseenIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) remoteViews.setOnClickPendingIntent(R.id.speakUnseenImage, speakUnseenPendingIntent) AppWidgetManager.getInstance(context).updateAppWidget(appWidgetId, remoteViews)