diff --git a/android-smsmms/src/main/java/com/android/mms/service_alt/MmsConfigManager.java b/android-smsmms/src/main/java/com/android/mms/service_alt/MmsConfigManager.java index a95170df8..7ab0edd8c 100755 --- a/android-smsmms/src/main/java/com/android/mms/service_alt/MmsConfigManager.java +++ b/android-smsmms/src/main/java/com/android/mms/service_alt/MmsConfigManager.java @@ -21,7 +21,8 @@ import android.content.Intent; import android.content.IntentFilter; import android.content.res.Configuration; -import android.os.Build; + +import androidx.core.content.ContextCompat; import android.telephony.SubscriptionInfo; import android.telephony.SubscriptionManager; import android.util.ArrayMap; @@ -79,7 +80,12 @@ public void init(final Context context) { IntentFilter intentFilterLoaded = new IntentFilter("LOADED"); try { - context.registerReceiver(mReceiver, intentFilterLoaded); + ContextCompat.registerReceiver( + context, + mReceiver, + intentFilterLoaded, + ContextCompat.RECEIVER_NOT_EXPORTED + ); } catch (Exception e) { } diff --git a/android-smsmms/src/main/java/com/android/mms/transaction/DownloadManager.java b/android-smsmms/src/main/java/com/android/mms/transaction/DownloadManager.java index a8f6de5ff..2c47d948b 100755 --- a/android-smsmms/src/main/java/com/android/mms/transaction/DownloadManager.java +++ b/android-smsmms/src/main/java/com/android/mms/transaction/DownloadManager.java @@ -8,6 +8,8 @@ import android.content.Intent; import android.content.IntentFilter; import android.database.Cursor; + +import androidx.core.content.ContextCompat; import android.database.sqlite.SqliteWrapper; import android.net.Uri; import android.os.Bundle; @@ -57,7 +59,12 @@ public void downloadMultimediaMessage(final Context context, final String locati mMap.put(location, receiver); // Use unique action in order to avoid cancellation of notifying download result. - context.getApplicationContext().registerReceiver(receiver, new IntentFilter(receiver.mAction)); + ContextCompat.registerReceiver( + context.getApplicationContext(), + receiver, + new IntentFilter(receiver.mAction), + ContextCompat.RECEIVER_NOT_EXPORTED + ); Timber.v("receiving with system method"); final String fileName = "download." + String.valueOf(Math.abs(new Random().nextLong())) + ".dat"; diff --git a/android-smsmms/src/main/java/com/android/mms/transaction/TransactionService.java b/android-smsmms/src/main/java/com/android/mms/transaction/TransactionService.java index a249350d9..c05e1032f 100755 --- a/android-smsmms/src/main/java/com/android/mms/transaction/TransactionService.java +++ b/android-smsmms/src/main/java/com/android/mms/transaction/TransactionService.java @@ -23,6 +23,8 @@ import android.content.Context; import android.content.Intent; import android.content.IntentFilter; + +import androidx.core.content.ContextCompat; import android.database.Cursor; import android.database.sqlite.SqliteWrapper; import android.net.ConnectivityManager; @@ -186,7 +188,12 @@ public void onCreate() { mReceiver = new ConnectivityBroadcastReceiver(); IntentFilter intentFilter = new IntentFilter(); intentFilter.addAction(ConnectivityManager.CONNECTIVITY_ACTION); - registerReceiver(mReceiver, intentFilter); + ContextCompat.registerReceiver( + this, + mReceiver, + intentFilter, + ContextCompat.RECEIVER_NOT_EXPORTED + ); mConnMgr = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE); } diff --git a/android-smsmms/src/main/java/com/android/mms/util/RateController.java b/android-smsmms/src/main/java/com/android/mms/util/RateController.java index fa4365dcb..97c295b46 100755 --- a/android-smsmms/src/main/java/com/android/mms/util/RateController.java +++ b/android-smsmms/src/main/java/com/android/mms/util/RateController.java @@ -21,6 +21,8 @@ import android.content.Context; import android.content.Intent; import android.content.IntentFilter; + +import androidx.core.content.ContextCompat; import android.database.Cursor; import android.database.sqlite.SqliteWrapper; import android.provider.Telephony.Mms.Rate; @@ -124,8 +126,12 @@ synchronized public boolean isAllowedByUser() { } sMutexLock = true; - mContext.registerReceiver(mBroadcastReceiver, - new IntentFilter(RATE_LIMIT_CONFIRMED_ACTION)); + ContextCompat.registerReceiver( + mContext, + mBroadcastReceiver, + new IntentFilter(RATE_LIMIT_CONFIRMED_ACTION), + ContextCompat.RECEIVER_NOT_EXPORTED + ); mAnswer = NO_ANSWER; try { diff --git a/data/src/main/java/com/moez/QKSMS/manager/BluetoothMicManager.kt b/data/src/main/java/com/moez/QKSMS/manager/BluetoothMicManager.kt index 8ddeec27e..d96430ad5 100644 --- a/data/src/main/java/com/moez/QKSMS/manager/BluetoothMicManager.kt +++ b/data/src/main/java/com/moez/QKSMS/manager/BluetoothMicManager.kt @@ -25,6 +25,7 @@ import android.content.IntentFilter import android.media.AudioDeviceInfo import android.media.AudioManager import android.media.AudioManager.GET_DEVICES_INPUTS +import androidx.core.content.ContextCompat // this class is, by design, as simplistic it can be to support easy and fast connection @@ -45,9 +46,12 @@ class BluetoothMicManager( init { // register for bluetooth sco broadcast intents - context.registerReceiver(this, IntentFilter().apply { - addAction(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED) - }) + ContextCompat.registerReceiver( + context, + this, + IntentFilter(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED), + ContextCompat.RECEIVER_NOT_EXPORTED + ) } enum class StartBluetoothDevice { diff --git a/data/src/main/java/com/moez/QKSMS/repository/EmojiReactionRepositoryImpl.kt b/data/src/main/java/com/moez/QKSMS/repository/EmojiReactionRepositoryImpl.kt index 10e939e2b..b59806f86 100644 --- a/data/src/main/java/com/moez/QKSMS/repository/EmojiReactionRepositoryImpl.kt +++ b/data/src/main/java/com/moez/QKSMS/repository/EmojiReactionRepositoryImpl.kt @@ -122,7 +122,30 @@ class EmojiReactionRepositoryImpl @Inject constructor( return requireNotNull(adapter.fromJson(json)) { "Invalid emoji patterns JSON" } } + override fun buildOutgoingReactionBody( + emoji: String, + targetMessageText: String, + isRemoval: Boolean + ): String? { + if (targetMessageText.isBlank()) return null + + val text = targetMessageText.trim() + val (added, removed) = when (emoji) { + "❤️" -> "Loved “$text”" to "Removed a heart from “$text”" + "👍" -> "Liked “$text”" to "Removed a like from “$text”" + "👎" -> "Disliked “$text”" to "Removed a dislike from “$text”" + "😂" -> "Laughed at “$text”" to "Removed a laugh from “$text”" + "‼️" -> "Emphasized “$text”" to "Removed an exclamation from “$text”" + "❓" -> "Questioned “$text”" to "Removed a question mark from “$text”" + else -> "Reacted $emoji to “$text”" to "Removed $emoji from “$text”" + } + + return if (isRemoval) removed else added + } + override fun parseEmojiReaction(body: String): ParsedEmojiReaction? { + Timber.d("Attempting to parse reaction from body: '$body'") + val removal = parseRemoval(body) if (removal != null) return removal @@ -133,10 +156,11 @@ class EmojiReactionRepositoryImpl @Inject constructor( val result = parser(match) if (result == null) continue - Timber.d("Reaction found with ${result.emoji}") + Timber.d("Reaction found with ${result.emoji} for message: '${result.originalMessage}'") return result } + Timber.d("No reaction pattern matched for body: '$body'") return null } @@ -170,17 +194,23 @@ class EmojiReactionRepositoryImpl @Inject constructor( .sort("date", Sort.DESCENDING) .findAll() val endTime = System.currentTimeMillis() - Timber.d("Found ${messages.size} messages as potential emoji targets in ${endTime - startTime}ms") + Timber.d("Searching for target message in thread $threadId: found ${messages.size} messages in ${endTime - startTime}ms") + Timber.d("Looking for message text: '$originalMessageText'") val match = messages.find { message -> - message.getText(false).trim() == originalMessageText.trim() + val msgText = message.getText(false).trim() + val matches = msgText == originalMessageText.trim() + if (!matches && msgText.isNotEmpty()) { + Timber.v(" Checked message ${message.id}: '$msgText' - no match") + } + matches } if (match != null) { Timber.d("Found match for reaction target: message ID ${match.id}") return match } - Timber.w("No target message found for reaction text: '$originalMessageText'") + Timber.w("No target message found for reaction text: '$originalMessageText' in thread $threadId") return null } diff --git a/data/src/main/java/com/moez/QKSMS/repository/MessageRepositoryImpl.kt b/data/src/main/java/com/moez/QKSMS/repository/MessageRepositoryImpl.kt index 0032a7a92..7df016fc2 100644 --- a/data/src/main/java/com/moez/QKSMS/repository/MessageRepositoryImpl.kt +++ b/data/src/main/java/com/moez/QKSMS/repository/MessageRepositoryImpl.kt @@ -401,9 +401,11 @@ open class MessageRepositoryImpl @Inject constructor( addresses: Collection, body: String, attachments: Collection, - delay: Int + delay: Int, + applySignature: Boolean ) { val signedBody = when { + !applySignature -> body prefs.signature.get().isEmpty() -> body body.isNotEmpty() -> body + '\n' + prefs.signature.get() else -> prefs.signature.get() @@ -683,6 +685,9 @@ open class MessageRepositoryImpl @Inject constructor( body: String, date: Long ): Message { + // Check if this is a reaction message before inserting + val parsedReaction = reactions.parseEmojiReaction(body) + // Insert the message to Realm val message = Message().apply { this.threadId = threadId @@ -696,6 +701,8 @@ open class MessageRepositoryImpl @Inject constructor( type = "sms" read = true seen = true + // Mark as reaction upfront so it's hidden from the start + isEmojiReaction = parsedReaction != null } // Insert the message to the native content provider @@ -730,6 +737,25 @@ open class MessageRepositoryImpl @Inject constructor( } } } + + // If this is a reaction message, find the target and link them + if (parsedReaction != null) { + managedMessage?.let { savedMessage -> + val targetMessage = reactions.findTargetMessage( + savedMessage.threadId, + parsedReaction.originalMessage, + realm + ) + realm.executeTransaction { + reactions.saveEmojiReaction( + savedMessage, + parsedReaction, + targetMessage, + realm, + ) + } + } + } } // On some devices, we can't obtain a threadId until after the first message is sent in a @@ -856,6 +882,9 @@ open class MessageRepositoryImpl @Inject constructor( body: String, sentTime: Long ): Message { + // Check if this is a reaction message before inserting + val parsedReaction = reactions.parseEmojiReaction(body) + // Insert the message to Realm val message = Message().apply { this.address = address @@ -869,6 +898,8 @@ open class MessageRepositoryImpl @Inject constructor( boxId = Sms.MESSAGE_TYPE_INBOX type = "sms" read = activeConversationManager.getActiveConversation() == threadId + // Mark as reaction upfront so it's hidden from the start + isEmojiReaction = parsedReaction != null } // Insert the message to the native content provider @@ -891,9 +922,9 @@ open class MessageRepositoryImpl @Inject constructor( realm.executeTransaction { managedMessage?.contentId = id } } - managedMessage?.let { savedMessage -> - val parsedReaction = reactions.parseEmojiReaction(body) - if (parsedReaction != null) { + // If this is a reaction message, find the target and link them + if (parsedReaction != null) { + managedMessage?.let { savedMessage -> val targetMessage = reactions.findTargetMessage( savedMessage.threadId, parsedReaction.originalMessage, diff --git a/domain/src/main/java/com/moez/QKSMS/interactor/SendMessage.kt b/domain/src/main/java/com/moez/QKSMS/interactor/SendMessage.kt index 434dd4ebb..867dd3629 100644 --- a/domain/src/main/java/com/moez/QKSMS/interactor/SendMessage.kt +++ b/domain/src/main/java/com/moez/QKSMS/interactor/SendMessage.kt @@ -40,7 +40,8 @@ class SendMessage @Inject constructor( val addresses: List, val body: String, val attachments: List = listOf(), - val delay: Int = 0 + val delay: Int = 0, + val applySignature: Boolean = true, ) override fun buildObservable(params: Params): Flowable<*> = Flowable.just(Unit) @@ -57,7 +58,7 @@ class SendMessage @Inject constructor( return@doOnNext params.apply { - messageRepo.sendMessage(subId, threadId, addresses, body, attachments, delay) + messageRepo.sendMessage(subId, threadId, addresses, body, attachments, delay, applySignature) } conversationRepo.updateConversations(threadId) @@ -71,4 +72,4 @@ class SendMessage @Inject constructor( } .flatMap { updateBadge.buildObservable(Unit) } // Update the widget -} \ No newline at end of file +} diff --git a/domain/src/main/java/com/moez/QKSMS/repository/EmojiReactionRepository.kt b/domain/src/main/java/com/moez/QKSMS/repository/EmojiReactionRepository.kt index 58be5d338..59482a4d9 100644 --- a/domain/src/main/java/com/moez/QKSMS/repository/EmojiReactionRepository.kt +++ b/domain/src/main/java/com/moez/QKSMS/repository/EmojiReactionRepository.kt @@ -26,6 +26,11 @@ data class ParsedEmojiReaction(val emoji: String, val originalMessage: String, v interface EmojiReactionRepository { fun parseEmojiReaction(body: String): ParsedEmojiReaction? + /** + * Builds an outgoing reaction/tapback body that matches iOS-style SMS fallback formatting. + */ + fun buildOutgoingReactionBody(emoji: String, targetMessageText: String, isRemoval: Boolean = false): String? + fun findTargetMessage(threadId: Long, originalMessageText: String, realm: Realm): Message? fun saveEmojiReaction( diff --git a/domain/src/main/java/com/moez/QKSMS/repository/MessageRepository.kt b/domain/src/main/java/com/moez/QKSMS/repository/MessageRepository.kt index 927a3bd5e..3c84ca56f 100644 --- a/domain/src/main/java/com/moez/QKSMS/repository/MessageRepository.kt +++ b/domain/src/main/java/com/moez/QKSMS/repository/MessageRepository.kt @@ -70,7 +70,8 @@ interface MessageRepository { addresses: Collection, body: String, attachments: Collection, - delay: Int = 0 + delay: Int = 0, + applySignature: Boolean = true, ) /** 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 5e06321af..48b47baf6 100644 --- a/domain/src/main/java/com/moez/QKSMS/util/Preferences.kt +++ b/domain/src/main/java/com/moez/QKSMS/util/Preferences.kt @@ -130,6 +130,7 @@ class Preferences @Inject constructor( val disableScreenshots = rxPrefs.getBoolean("disableScreenshots", false) val logging = rxPrefs.getBoolean("logging", false) val unreadAtTop = rxPrefs.getBoolean("unreadAtTop", false) + val bubbles = rxPrefs.getBoolean("bubbles", false) init { // Migrate from old night mode preference to new one, now that we support android Q night mode diff --git a/presentation/src/main/AndroidManifest.xml b/presentation/src/main/AndroidManifest.xml index 228120864..cf3710d8c 100644 --- a/presentation/src/main/AndroidManifest.xml +++ b/presentation/src/main/AndroidManifest.xml @@ -77,7 +77,10 @@ android:name=".feature.compose.ComposeActivity" android:exported="true" android:parentActivityName=".feature.main.MainActivity" - android:windowSoftInputMode="stateHidden"> + android:windowSoftInputMode="stateHidden" + android:documentLaunchMode="always" + android:allowEmbedded="true" + android:resizeableActivity="true"> 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 4fe8b312b..35db17977 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 @@ -33,6 +33,7 @@ import android.os.PowerManager import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.Person +import androidx.core.graphics.drawable.IconCompat import androidx.core.app.RemoteInput import androidx.core.app.TaskStackBuilder import androidx.core.content.getSystemService @@ -452,6 +453,34 @@ class NotificationManagerImpl @Inject constructor( } val sc = shortcutManager.getShortcut(threadId) notification.setShortcutInfo(sc) + + // Add bubble metadata for Android 11+ when bubbles are enabled + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && prefs.bubbles.get()) { + val bubbleIntent = PendingIntent.getActivity( + context, + threadId.toInt(), + Intent(context, ComposeActivity::class.java) + .putExtra("threadId", threadId) + .setAction(Intent.ACTION_VIEW) + .setData(Uri.parse("sms:${conversation.recipients.firstOrNull()?.address}")), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + ) + + val bubbleIcon = avatar?.let { IconCompat.createWithAdaptiveBitmap(it) } + ?: IconCompat.createWithResource(context, R.drawable.ic_person_black_24dp) + + val bubbleMetadata = NotificationCompat.BubbleMetadata.Builder( + bubbleIntent, + bubbleIcon + ) + .setDesiredHeight(600) + .setAutoExpandBubble(false) + .setSuppressNotification(false) + .build() + + notification.setBubbleMetadata(bubbleMetadata) + } + notificationManager.notify(threadId.toInt(), notification.build()) // Wake screen @@ -581,6 +610,9 @@ class NotificationManagerImpl @Inject constructor( lightColor = Color.WHITE enableVibration(true) vibrationPattern = VIBRATE_PATTERN + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + setAllowBubbles(true) + } } else -> { @@ -599,6 +631,9 @@ class NotificationManagerImpl @Inject constructor( .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) .build() ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + setAllowBubbles(true) + } } } } diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeActivity.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeActivity.kt index e8045cd27..bbaf2f6bb 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeActivity.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeActivity.kt @@ -23,10 +23,13 @@ import android.animation.LayoutTransition import android.app.Activity import android.app.DatePickerDialog import android.app.TimePickerDialog +import android.content.ClipData +import android.content.ClipboardManager import android.content.ContentValues import android.content.DialogInterface import android.content.Intent import android.content.res.ColorStateList +import android.graphics.drawable.ColorDrawable import android.net.Uri import android.os.Bundle import android.os.SystemClock @@ -37,6 +40,8 @@ import android.view.ContextMenu import android.view.Menu import android.view.MenuItem import android.view.View +import android.view.ViewGroup +import android.widget.PopupWindow import android.widget.SeekBar import androidx.appcompat.app.AlertDialog import androidx.constraintlayout.widget.ConstraintSet @@ -55,6 +60,7 @@ import com.uber.autodispose.autoDispose import dagger.android.AndroidInjection import io.reactivex.Observable import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.CompositeDisposable import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers import io.reactivex.subjects.PublishSubject @@ -79,6 +85,8 @@ import org.prauga.messages.feature.compose.editing.ChipsAdapter import org.prauga.messages.feature.contacts.ContactsActivity import org.prauga.messages.model.Attachment import org.prauga.messages.model.Recipient +import org.prauga.messages.feature.compose.MessageLongPress +import org.prauga.messages.feature.compose.ReactionSelection import java.text.SimpleDateFormat import java.util.Calendar import java.util.Date @@ -191,6 +199,10 @@ class ComposeActivity : QkThemedActivity(ComposeActivity override val recordAudioMsgRecordVisible: Subject = PublishSubject.create() override val recordAudioChronometer: Subject = PublishSubject.create() override val recordAudioRecord: Subject = PublishSubject.create() + override val reactionSelectedIntent: Subject = PublishSubject.create() + override val messageLongPressIntent by lazy { messageAdapter.messageLongPresses } + + private val disposables = CompositeDisposable() private var seekBarUpdater: Disposable? = null @@ -373,6 +385,12 @@ class ComposeActivity : QkThemedActivity(ComposeActivity ) window.callback = ComposeWindowCallback(window.callback, this) + + disposables.add( + messageLongPressIntent.subscribe { payload -> + showReactionSheet(payload) + } + ) } override fun onStart() { @@ -392,6 +410,7 @@ class ComposeActivity : QkThemedActivity(ComposeActivity QkMediaPlayer.reset() seekBarUpdater?.dispose() + disposables.clear() } @@ -444,6 +463,8 @@ class ComposeActivity : QkThemedActivity(ComposeActivity !state.editingMode && state.selectedMessages > 0 && state.selectedMessagesHaveText binding.toolbar.menu.findItem(R.id.details)?.isVisible = !state.editingMode && state.selectedMessages == 1 + binding.toolbar.menu.findItem(R.id.react)?.isVisible = + !state.editingMode && state.selectedMessagesCanReact binding.toolbar.menu.findItem(R.id.delete)?.isVisible = !state.editingMode && ((state.selectedMessages > 0) || state.canSend) binding.toolbar.menu.findItem(R.id.forward)?.isVisible = @@ -843,6 +864,125 @@ class ComposeActivity : QkThemedActivity(ComposeActivity binding.message.requestFocus() } + private fun showReactionSheet(payload: MessageLongPress) { + val view = layoutInflater.inflate(R.layout.reaction_sheet, null) + + val reactions = listOf( + view.findViewById(R.id.reaction_love) to "❤️", + view.findViewById(R.id.reaction_like) to "👍", + view.findViewById(R.id.reaction_dislike) to "👎", + view.findViewById(R.id.reaction_laugh) to "😂", + view.findViewById(R.id.reaction_emphasize) to "‼️", + view.findViewById(R.id.reaction_question) to "❓", + ) + + val popup = PopupWindow( + view, + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + true + ).apply { + elevation = 16f + isOutsideTouchable = true + setBackgroundDrawable(ColorDrawable(android.graphics.Color.TRANSPARENT)) + } + + reactions.forEach { (button, emoji) -> + button.setOnClickListener { + reactionSelectedIntent.onNext(ReactionSelection(payload.messageId, emoji)) + popup.dismiss() + } + } + + view.findViewById(R.id.action_copy).setOnClickListener { + val clipboard = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("message", payload.body)) + Snackbar.make(binding.root, R.string.toast_copied, Snackbar.LENGTH_SHORT).show() + popup.dismiss() + } + + view.findViewById(R.id.action_share).setOnClickListener { + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, payload.body) + } + startActivity(Intent.createChooser(intent, getString(R.string.compose_menu_share))) + popup.dismiss() + } + + view.findViewById(R.id.action_select_all).setOnClickListener { + popup.dismiss() + optionsItemIntent.onNext(R.id.select_all) + } + + view.findViewById(R.id.action_delete).setOnClickListener { + popup.dismiss() + optionsItemIntent.onNext(R.id.delete) + } + + view.findViewById(R.id.action_forward).setOnClickListener { + popup.dismiss() + optionsItemIntent.onNext(R.id.forward) + } + + view.findViewById(R.id.action_details).setOnClickListener { + popup.dismiss() + optionsItemIntent.onNext(R.id.show_status) + } + + // Measure popup content to calculate proper positioning + view.measure( + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) + ) + val popupWidth = view.measuredWidth + val popupHeight = view.measuredHeight + + // Get anchor location and screen dimensions + val location = IntArray(2) + payload.anchor.getLocationOnScreen(location) + val anchorX = location[0] + val anchorY = location[1] + val anchorWidth = payload.anchor.width + val anchorHeight = payload.anchor.height + + val displayMetrics = resources.displayMetrics + val screenWidth = displayMetrics.widthPixels + val screenHeight = displayMetrics.heightPixels + + // Calculate X position - center on anchor, but keep within screen bounds + var xPos = anchorX + (anchorWidth - popupWidth) / 2 + xPos = xPos.coerceIn(16, screenWidth - popupWidth - 16) + + // Calculate Y position - prefer above the anchor, fall back to below if no space + val yPos = if (anchorY - popupHeight > 0) { + anchorY - popupHeight - 8 + } else { + anchorY + anchorHeight + 8 + } + + popup.showAtLocation(payload.anchor, android.view.Gravity.NO_GRAVITY, xPos, yPos) + } + + override fun showReactionPicker(messageId: Long) { + val options = listOf( + ReactionSelection(messageId, "❤️") to getString(R.string.reaction_picker_love), + ReactionSelection(messageId, "👍") to getString(R.string.reaction_picker_like), + ReactionSelection(messageId, "👎") to getString(R.string.reaction_picker_dislike), + ReactionSelection(messageId, "😂") to getString(R.string.reaction_picker_laugh), + ReactionSelection(messageId, "‼️") to getString(R.string.reaction_picker_emphasize), + ReactionSelection(messageId, "❓") to getString(R.string.reaction_picker_question), + ) + + AlertDialog.Builder(this) + .setTitle(R.string.compose_menu_react) + .setItems(options.map { it.second }.toTypedArray()) { _, index -> + reactionSelectedIntent.onNext(options[index].first) + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + private fun tintDialogButtons(dialog: android.app.AlertDialog) { dialog.setOnShowListener { val color = resolveThemeColor(android.R.attr.textColorPrimary, colors.theme().textPrimary) diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeState.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeState.kt index 062e51449..8e252fb89 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeState.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeState.kt @@ -40,6 +40,7 @@ data class ComposeState( val messages: Pair>? = null, val selectedMessages: Int = 0, val selectedMessagesHaveText: Boolean = false, + val selectedMessagesCanReact: Boolean = false, val scheduled: Long = 0, val attachments: List = listOf(), val attaching: Boolean = false, @@ -51,4 +52,4 @@ data class ComposeState( val validRecipientNumbers: Int = 1, val audioMsgRecording: Boolean = false, val saveDraft: Boolean = true, -) \ No newline at end of file +) diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeView.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeView.kt index 52c69713f..637dcb6f9 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeView.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeView.kt @@ -31,6 +31,9 @@ import org.prauga.messages.common.widget.MicInputCloudView import org.prauga.messages.model.Attachment import org.prauga.messages.model.Recipient +data class ReactionSelection(val messageId: Long, val emoji: String, val isRemoval: Boolean = false) +data class MessageLongPress(val messageId: Long, val body: String, val anchor: View) + interface ComposeView : QkView { companion object { @@ -87,6 +90,8 @@ interface ComposeView : QkView { val recordAudioMsgRecordVisible: Subject val recordAudioRecord: Subject val recordAudioChronometer: Subject + val reactionSelectedIntent: Subject + val messageLongPressIntent: Observable fun clearSelection() fun toggleSelectAll() @@ -110,4 +115,5 @@ interface ComposeView : QkView { fun showDeleteDialog(messages: List) fun showClearCurrentMessageDialog() fun focusMessage() + fun showReactionPicker(messageId: Long) } diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeViewModel.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeViewModel.kt index 97c2333b4..e45a85e9a 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeViewModel.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeViewModel.kt @@ -81,6 +81,7 @@ import org.prauga.messages.model.Recipient import org.prauga.messages.model.getText import org.prauga.messages.repository.ContactRepository import org.prauga.messages.repository.ConversationRepository +import org.prauga.messages.repository.EmojiReactionRepository import org.prauga.messages.repository.MessageRepository import org.prauga.messages.repository.ScheduledMessageRepository import org.prauga.messages.util.ActiveSubscriptionObservable @@ -116,6 +117,7 @@ class ComposeViewModel @Inject constructor( private val markRead: MarkRead, private val messageDetailsFormatter: MessageDetailsFormatter, private val messageRepo: MessageRepository, + private val reactions: EmojiReactionRepository, private val scheduledMessageRepo: ScheduledMessageRepository, private val navigator: Navigator, private val permissionManager: PermissionManager, @@ -495,6 +497,60 @@ class ComposeViewModel @Inject constructor( .autoDispose(view.scope()) .subscribe { view.showDetails(it) } + // Open the reaction picker for a single selected message + view.optionsItemIntent + .filter { it == R.id.react } + .withLatestFrom(view.messagesSelectedIntent) { _, messages -> messages.firstOrNull() ?: -1L } + .filter { it != -1L } + .autoDispose(view.scope()) + .subscribe { messageId -> + view.showReactionPicker(messageId) + } + + view.reactionSelectedIntent + .withLatestFrom(conversation, state) { selection, convo, state -> + Triple(selection, convo, state) + } + .autoDispose(view.scope()) + .subscribe { (selection, convo, currentState) -> + if (!permissionManager.isDefaultSms()) { + view.requestDefaultSms() + return@subscribe + } + + if (!permissionManager.hasSendSms()) { + view.requestSmsPermission() + return@subscribe + } + + val targetMessage = messageRepo.getMessage(selection.messageId) ?: return@subscribe + + val body = reactions.buildOutgoingReactionBody( + selection.emoji, + targetMessage.getText(false), + selection.isRemoval + ) ?: return@subscribe + + val addresses = when { + convo.recipients.isNotEmpty() -> convo.recipients.map { it.address } + else -> listOf(targetMessage.address) + } + + val params = SendMessage.Params( + currentState.subscription?.subscriptionId ?: -1, + convo.id.takeIf { it != 0L } ?: targetMessage.threadId, + addresses, + body, + listOf(), + 0, + applySignature = false, + ) + + sendMessage.execute(params) { + view.clearSelection() + } + } + // Show the delete message dialog if one or more messages selected view.optionsItemIntent .filter { it == R.id.delete } @@ -675,10 +731,13 @@ class ComposeViewModel @Inject constructor( // Update the State when the message selected count changes view.messagesSelectedIntent .map { - Pair( - it.size, - it.any { messageRepo.getMessage(it)?.hasNonWhitespaceText() ?: false } - ) + val selectedMessages = it.mapNotNull(messageRepo::getMessage) + val hasText = selectedMessages.any { msg -> msg.hasNonWhitespaceText() } + val canReact = selectedMessages.singleOrNull()?.let { msg -> + !msg.isMe() && !msg.isEmojiReaction && msg.hasNonWhitespaceText() + } ?: false + + Triple(selectedMessages.size, hasText, canReact) } .autoDispose(view.scope()) .subscribe { @@ -686,6 +745,7 @@ class ComposeViewModel @Inject constructor( copy( selectedMessages = it.first, selectedMessagesHaveText = it.second, + selectedMessagesCanReact = it.third, editingMode = false ) } diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/MessagesAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/MessagesAdapter.kt index 2775f1c4c..9554e5908 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/MessagesAdapter.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/MessagesAdapter.kt @@ -72,6 +72,7 @@ import org.prauga.messages.feature.extensions.isEmojiOnly import org.prauga.messages.model.Conversation import org.prauga.messages.model.Message import org.prauga.messages.model.Recipient +import org.prauga.messages.feature.compose.MessageLongPress import org.prauga.messages.util.PhoneNumberUtils import org.prauga.messages.util.Preferences import java.util.concurrent.TimeUnit @@ -153,6 +154,7 @@ class MessagesAdapter @Inject constructor( val sendNowClicks: Subject = PublishSubject.create() val resendClicks: Subject = PublishSubject.create() val partContextMenuRegistrar: Subject = PublishSubject.create() + val messageLongPresses: Subject = PublishSubject.create() var data: Pair>? = null set(value) { @@ -210,6 +212,13 @@ class MessagesAdapter @Inject constructor( getItem(adapterPosition)?.let { toggleSelection(it.id) containerView.isActivated = isSelected(it.id) + messageLongPresses.onNext( + MessageLongPress( + it.id, + it.getText(false), + containerView + ) + ) } true } @@ -238,6 +247,13 @@ class MessagesAdapter @Inject constructor( getItem(adapterPosition)?.let { toggleSelection(it.id) containerView.isActivated = isSelected(it.id) + messageLongPresses.onNext( + MessageLongPress( + it.id, + it.getText(false), + containerView + ) + ) } true } @@ -489,21 +505,14 @@ class MessagesAdapter @Inject constructor( val hasReactions = reactions.isNotEmpty() if (hasReactions) { - val reactionCounts = reactions.groupBy { it.emoji } - .mapValues { it.value.size } + // Get unique emojis sorted by count (most popular first) + val uniqueEmojis = reactions.groupBy { it.emoji } .toList() - .sortedByDescending { it.second } // Sort by count, most reactions first - - // For now, show just the first (most popular) reaction - val topReaction = reactionCounts.first() - val reactionText = if (topReaction.second == 1) { - topReaction.first - } else { - // Use a non-breaking space to keep the emoji and count together - "${topReaction.first}\u00A0${topReaction.second}" - } + .sortedByDescending { it.second.size } + .map { it.first } + .joinToString("") - binding.reactionText.text = reactionText + binding.reactionText.text = uniqueEmojis binding.reactions.setVisible(true) makeRoomForEmojis(binding.reactions) } else { diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/notificationprefs/NotificationPrefsActivity.kt b/presentation/src/main/java/com/moez/QKSMS/feature/notificationprefs/NotificationPrefsActivity.kt index ed47356e3..f07c0dfbf 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/notificationprefs/NotificationPrefsActivity.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/notificationprefs/NotificationPrefsActivity.kt @@ -76,10 +76,13 @@ class NotificationPrefsActivity : QkThemedActivity= Build.VERSION_CODES.O + val hasR = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R + binding.notificationsO.setVisible(hasOreo) binding.notifications.setVisible(!hasOreo) binding.vibration.setVisible(!hasOreo) binding.ringtone.setVisible(!hasOreo) + binding.bubbles.setVisible(hasR) previewModeDialog.setTitle(R.string.settings_notification_previews_title) previewModeDialog.adapter.setData(R.array.notification_preview_options) @@ -106,6 +109,8 @@ class NotificationPrefsActivity : QkThemedActivity(R.id.checkbox)?.isChecked = state.wakeEnabled binding.silentNotContact.findViewById(R.id.checkbox)?.isChecked = state.silentNotContact binding.silentNotContact.isVisible = state.threadId == 0L + binding.bubbles.findViewById(R.id.checkbox)?.isChecked = state.bubblesEnabled + binding.bubbles.isVisible = state.threadId == 0L && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R binding.vibration.findViewById(R.id.checkbox)?.isChecked = state.vibrationEnabled binding.ringtone.summary = state.ringtoneName diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/notificationprefs/NotificationPrefsState.kt b/presentation/src/main/java/com/moez/QKSMS/feature/notificationprefs/NotificationPrefsState.kt index 2ccb38e65..932a9856e 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/notificationprefs/NotificationPrefsState.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/notificationprefs/NotificationPrefsState.kt @@ -29,6 +29,7 @@ data class NotificationPrefsState( val previewId: Int = Preferences.NOTIFICATION_PREVIEWS_ALL, val wakeEnabled: Boolean = false, val silentNotContact: Boolean = false, + val bubblesEnabled: Boolean = false, val action1Summary: String = "", val action2Summary: String = "", val action3Summary: String = "", diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/notificationprefs/NotificationPrefsViewModel.kt b/presentation/src/main/java/com/moez/QKSMS/feature/notificationprefs/NotificationPrefsViewModel.kt index 42c8fdd25..059a613dd 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/notificationprefs/NotificationPrefsViewModel.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/notificationprefs/NotificationPrefsViewModel.kt @@ -81,6 +81,9 @@ class NotificationPrefsViewModel @Inject constructor( disposables += prefs.silentNotContact.asObservable() .subscribe { enabled -> newState { copy(silentNotContact = enabled) } } + disposables += prefs.bubbles.asObservable() + .subscribe { enabled -> newState { copy(bubblesEnabled = enabled) } } + disposables += vibration.asObservable() .subscribe { enabled -> newState { copy(vibrationEnabled = enabled) } } @@ -117,6 +120,8 @@ class NotificationPrefsViewModel @Inject constructor( R.id.silentNotContact -> prefs.silentNotContact.set(!prefs.silentNotContact.get()) + R.id.bubbles -> prefs.bubbles.set(!prefs.bubbles.get()) + R.id.vibration -> vibration.set(!vibration.get()) R.id.ringtone -> view.showRingtonePicker( diff --git a/presentation/src/main/res/layout/notification_prefs_activity.xml b/presentation/src/main/res/layout/notification_prefs_activity.xml index d8b33361e..702fcf408 100644 --- a/presentation/src/main/res/layout/notification_prefs_activity.xml +++ b/presentation/src/main/res/layout/notification_prefs_activity.xml @@ -87,6 +87,14 @@ app:summary="@string/settings_notification_silent_summary" app:widget="@layout/settings_switch_widget" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/presentation/src/main/res/menu/compose.xml b/presentation/src/main/res/menu/compose.xml index c88f53154..0e0d6fcdf 100644 --- a/presentation/src/main/res/menu/compose.xml +++ b/presentation/src/main/res/menu/compose.xml @@ -87,6 +87,11 @@ android:title="@string/compose_menu_forward" android:visible="false" app:showAsAction="ifRoom" /> + Copy text Share text Forward + React + More Show status Delete Previous @@ -181,6 +183,12 @@ %s selected, change SIM card Send message Record audio message + ❤️ Love + 👍 Like + 👎 Dislike + 😂 Laugh + ‼️ Emphasize + ❓ Question Cancel audio message Attach audio message @@ -293,6 +301,8 @@ Popup for new messages Tap to dismiss Tap outside of the popup to close it + Chat bubbles + Show conversations as floating bubbles Delayed sending Swipe actions Configure swipe actions for conversations