diff --git a/android-smsmms/src/main/java/com/android/mms/service_alt/DownloadRequest.java b/android-smsmms/src/main/java/com/android/mms/service_alt/DownloadRequest.java index 4a4cd7497..4546f2c1c 100755 --- a/android-smsmms/src/main/java/com/android/mms/service_alt/DownloadRequest.java +++ b/android-smsmms/src/main/java/com/android/mms/service_alt/DownloadRequest.java @@ -97,12 +97,13 @@ public static Uri persist(Context context, byte[] response, MmsConfig.Overridden }); return null; } + Timber.d("DownloadRequest.persist: responseLength=" + response.length + " locationUrl=" + locationUrl + " supportContentDisposition=" + mmsConfig.getSupportMmsContentDisposition()); final long identity = Binder.clearCallingIdentity(); try { final GenericPdu pdu = (new PduParser(response, mmsConfig.getSupportMmsContentDisposition())).parse(); if (pdu == null || !(pdu instanceof RetrieveConf)) { - Timber.e("DownloadRequest.persistIfRequired: invalid parsed PDU"); + Timber.e("DownloadRequest.persistIfRequired: invalid parsed PDU. pdu=" + pdu + (pdu != null ? " type=" + pdu.getMessageType() : "")); // Update the error type of the NotificationInd setErrorType(context, locationUrl, Telephony.MmsSms.ERR_TYPE_MMS_PROTO_PERMANENT); @@ -110,6 +111,7 @@ public static Uri persist(Context context, byte[] response, MmsConfig.Overridden } final RetrieveConf retrieveConf = (RetrieveConf) pdu; final int status = retrieveConf.getRetrieveStatus(); + Timber.d("DownloadRequest.persist: PDU parsed ok, retrieveStatus=" + status + " partCount=" + retrieveConf.getBody().getPartsNum()); // if (status != PduHeaders.RETRIEVE_STATUS_OK) { // Timber.e("DownloadRequest.persistIfRequired: retrieve failed " // + status); @@ -131,6 +133,7 @@ public static Uri persist(Context context, byte[] response, MmsConfig.Overridden // Store the downloaded message final PduPersister persister = PduPersister.getPduPersister(context); final Uri messageUri = persister.persist(pdu, Telephony.Mms.Inbox.CONTENT_URI, PduPersister.DUMMY_THREAD_ID, true, true, null); + Timber.d("DownloadRequest.persist: PduPersister.persist returned uri=" + messageUri); if (messageUri == null) { Timber.e("DownloadRequest.persistIfRequired: can not persist message"); return null; 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/PushReceiver.java b/android-smsmms/src/main/java/com/android/mms/transaction/PushReceiver.java index 722b2f834..b76766b1d 100755 --- a/android-smsmms/src/main/java/com/android/mms/transaction/PushReceiver.java +++ b/android-smsmms/src/main/java/com/android/mms/transaction/PushReceiver.java @@ -148,6 +148,7 @@ protected Void doInBackground(Intent... intents) { } int subId = intent.getIntExtra("subscription", Utils.getDefaultSubscriptionId()); + Timber.d("PushReceiver: MMS notification received, triggering download. location=" + location + " notifUri=" + uri + " subId=" + subId); DownloadManager.getInstance().downloadMultimediaMessage(mContext, location, uri, true, subId); } else { Timber.v("Skip downloading duplicate message: " + new String(nInd.getContentLocation())); 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/android-smsmms/src/main/java/com/klinker/android/send_message/Transaction.kt b/android-smsmms/src/main/java/com/klinker/android/send_message/Transaction.kt index 1ff39dd18..fad186c84 100755 --- a/android-smsmms/src/main/java/com/klinker/android/send_message/Transaction.kt +++ b/android-smsmms/src/main/java/com/klinker/android/send_message/Transaction.kt @@ -119,6 +119,9 @@ class Transaction @JvmOverloads constructor(private val context: Context, settin } catch (e: IOException) { Timber.e(e, "Error writing send file") null + } catch (e: OutOfMemoryError) { + Timber.e(e, "Not enough memory to compose MMS") + null } val configOverrides = bundleOf( @@ -140,7 +143,7 @@ class Transaction @JvmOverloads constructor(private val context: Context, settin } } - } catch (e: Exception) { + } catch (e: Throwable) { Timber.e(e, "Error using system sending method") } 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/receiver/MmsReceivedReceiver.kt b/data/src/main/java/com/moez/QKSMS/receiver/MmsReceivedReceiver.kt index c92f4b1b9..a7aa51c32 100644 --- a/data/src/main/java/com/moez/QKSMS/receiver/MmsReceivedReceiver.kt +++ b/data/src/main/java/com/moez/QKSMS/receiver/MmsReceivedReceiver.kt @@ -24,6 +24,7 @@ import android.net.Uri import com.klinker.android.send_message.MmsReceivedReceiver import dagger.android.AndroidInjection import org.prauga.messages.interactor.ReceiveMms +import timber.log.Timber import javax.inject.Inject class MmsReceivedReceiver : MmsReceivedReceiver() { @@ -31,15 +32,19 @@ class MmsReceivedReceiver : MmsReceivedReceiver() { @Inject lateinit var receiveMms: ReceiveMms override fun onReceive(context: Context?, intent: Intent?) { + Timber.d("MmsReceivedReceiver.onReceive: action=${intent?.action} extras=${intent?.extras?.keySet()}") AndroidInjection.inject(this, context) super.onReceive(context, intent) } override fun onMessageReceived(messageUri: Uri?) { - messageUri?.let { uri -> - val pendingResult = goAsync() - receiveMms.execute(uri) { pendingResult.finish() } + Timber.d("MmsReceivedReceiver.onMessageReceived: uri=$messageUri") + if (messageUri == null) { + Timber.w("MmsReceivedReceiver.onMessageReceived: uri is null, skipping sync") + return } + val pendingResult = goAsync() + receiveMms.execute(messageUri) { pendingResult.finish() } } } diff --git a/data/src/main/java/com/moez/QKSMS/receiver/SmsReceiver.kt b/data/src/main/java/com/moez/QKSMS/receiver/SmsReceiver.kt index 08d808adb..899d4a95a 100644 --- a/data/src/main/java/com/moez/QKSMS/receiver/SmsReceiver.kt +++ b/data/src/main/java/com/moez/QKSMS/receiver/SmsReceiver.kt @@ -22,14 +22,9 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.provider.Telephony.Sms -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.OutOfQuotaPolicy -import androidx.work.WorkManager -import androidx.work.workDataOf import dagger.android.AndroidInjection +import org.prauga.messages.interactor.ReceiveSms import org.prauga.messages.repository.MessageRepository -import org.prauga.messages.worker.ReceiveSmsWorker -import org.prauga.messages.worker.ReceiveSmsWorker.Companion.INPUT_DATA_KEY_MESSAGE_ID import io.reactivex.Single import io.reactivex.schedulers.Schedulers import timber.log.Timber @@ -37,16 +32,19 @@ import javax.inject.Inject class SmsReceiver : BroadcastReceiver() { @Inject lateinit var messageRepo: MessageRepository + @Inject lateinit var receiveSms: ReceiveSms override fun onReceive(context: Context, intent: Intent) { AndroidInjection.inject(this, context) + val pendingResult = goAsync() + Sms.Intents.getMessagesFromIntent(intent)?.let { messages -> // reduce list of messages to single message and save in db val messageId = Single.just(messages) .observeOn(Schedulers.io()) .map { - Timber.v("onReceive() new sms") // here so runs on io thread + Timber.v("onReceive() new sms") messageRepo.insertReceivedSms( intent.extras?.getInt("subscription", -1) ?: -1, @@ -57,14 +55,8 @@ class SmsReceiver : BroadcastReceiver() { } .blockingGet() - // start worker with message id as param - WorkManager.getInstance(context).enqueue( - OneTimeWorkRequestBuilder() - .setInputData(workDataOf(INPUT_DATA_KEY_MESSAGE_ID to messageId)) - .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) - .build() - ) - } + receiveSms.execute(messageId) { pendingResult.finish() } + } ?: 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 0ed58be19..ab6b55924 100644 --- a/data/src/main/java/com/moez/QKSMS/repository/ConversationRepositoryImpl.kt +++ b/data/src/main/java/com/moez/QKSMS/repository/ConversationRepositoryImpl.kt @@ -41,6 +41,7 @@ import org.prauga.messages.model.Conversation import org.prauga.messages.model.Message import org.prauga.messages.model.Recipient import org.prauga.messages.model.SearchResult +import org.prauga.messages.model.SearchItem import org.prauga.messages.util.PhoneNumberUtils import org.prauga.messages.util.tryOrNull import java.util.concurrent.TimeUnit @@ -207,6 +208,77 @@ class ConversationRepositoryImpl @Inject constructor( return conversationMatches + messagesByConversation } + override fun searchConversationsGrouped(query: CharSequence): List { + val realm = Realm.getDefaultInstance() + + val normalizedQuery = query.removeAccents() + val conversations = realm.copyFromRealm( + realm + .where(Conversation::class.java) + .notEqualTo("id", 0L) + .isNotNull("lastMessage") + .equalTo("blocked", false) + .isNotEmpty("recipients") + .sort("pinned", Sort.DESCENDING, "lastMessage.date", Sort.DESCENDING) + .findAll() + ) + + val conversationsById = conversations.associateBy { it.id } + + // Get all messages matching the query, grouped by conversation + val messagesByConversation = realm.copyFromRealm( + realm + .where(Message::class.java) + .beginGroup() + .contains("body", normalizedQuery, Case.INSENSITIVE) + .or() + .contains("parts.text", normalizedQuery, Case.INSENSITIVE) + .endGroup() + .sort("date", Sort.DESCENDING) + .findAll() + ) + .groupBy { message -> message.threadId } + .mapNotNull { (threadId, messages) -> + conversationsById[threadId]?.let { conversation -> + Pair(conversation, messages) + } + } + .sortedByDescending { (_, messages) -> messages.size } + + realm.close() + + // Build the flattened list with headers and messages + val result = mutableListOf() + + messagesByConversation.forEach { (conversation, messages) -> + // Add conversation header + result.add(SearchItem.Header( + conversationId = conversation.id, + title = conversation.getTitle() + )) + + // Add matching messages + messages.forEach { message -> + val body = when { + message.body.isNotEmpty() -> message.body + message.parts.isNotEmpty() -> message.parts.firstOrNull()?.text ?: "" + else -> "" + } + + if (body.isNotEmpty()) { + result.add(SearchItem.Message( + messageId = message.id, + conversationId = conversation.id, + body = body, + timestamp = message.date + )) + } + } + } + + return result + } + override fun getBlockedConversations(): RealmResults = Realm.getDefaultInstance() .where(Conversation::class.java) @@ -365,7 +437,10 @@ class ConversationRepositoryImpl @Inject constructor( getConversation(addresses) ?: tryOrNull { TelephonyCompat.getOrCreateThreadId(context, addresses.toSet()) } ?.takeIf { it != 0L } - ?.let { threadId -> getOrCreateConversation(threadId) } + ?.let { threadId -> + getOrCreateConversation(threadId) + ?: createConversationFromAddresses(threadId, addresses) + } } override fun saveDraft(threadId: Long, draft: String) = @@ -545,4 +620,51 @@ class ConversationRepositoryImpl @Inject constructor( } } } + + /** + * Fallback for when the content provider doesn't list the conversation (e.g. new thread with + * no messages yet). Creates the Conversation directly in Realm using the known addresses and + * canonical address IDs from the telephony provider. + */ + private fun createConversationFromAddresses(threadId: Long, addresses: Collection): Conversation? = + tryOrNull(true) { + // Query all canonical addresses from the telephony provider + val allCanonicalRecipients = cursorToRecipient.getRecipientCursor() + ?.use { cursor -> cursor.map { cursorToRecipient.map(it) } } + ?: emptyList() + + Realm.getDefaultInstance().use { realm -> + realm.refresh() + val realmContacts = realm.where(Contact::class.java).findAll() + + val recipients = addresses.map { address -> + // Find the canonical address entry that getOrCreateThreadId created + val canonical = allCanonicalRecipients.firstOrNull { recipient -> + phoneNumberUtils.compare(recipient.address, address) + } + + Recipient( + id = canonical?.id ?: 0L, + address = canonical?.address ?: address, + lastUpdate = System.currentTimeMillis() + ).apply { + contact = realmContacts.firstOrNull { realmContact -> + realmContact.numbers.any { + phoneNumberUtils.compare(it.address, address) + } + } + } + } + + val conversation = Conversation().apply { + id = threadId + this.recipients.clear() + this.recipients.addAll(recipients) + } + + realm.executeTransaction { it.insertOrUpdate(conversation) } + } + + getConversation(threadId) + } } 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/data/src/main/java/com/moez/QKSMS/repository/SyncRepositoryImpl.kt b/data/src/main/java/com/moez/QKSMS/repository/SyncRepositoryImpl.kt index 8e5c84756..979678e72 100644 --- a/data/src/main/java/com/moez/QKSMS/repository/SyncRepositoryImpl.kt +++ b/data/src/main/java/com/moez/QKSMS/repository/SyncRepositoryImpl.kt @@ -258,6 +258,8 @@ class SyncRepositoryImpl @Inject constructor( // If we don't have a valid id, return null val id = tryOrNull(false) { ContentUris.parseId(uri) } ?: return null + Timber.d("syncMessage: type=$type id=$id uri=$uri") + // Check if the message already exists, so we can reuse the id val existingId = Realm.getDefaultInstance().use { realm -> realm.refresh() @@ -267,6 +269,7 @@ class SyncRepositoryImpl @Inject constructor( .findFirst() ?.id } + Timber.d("syncMessage: existingRealmId=$existingId") // The uri might be something like content://mms/inbox/id // The box might change though, so we should just use the mms/id uri @@ -275,23 +278,37 @@ class SyncRepositoryImpl @Inject constructor( else -> ContentUris.withAppendedId(Telephony.Sms.CONTENT_URI, id) } - return contentResolver.query(stableUri, null, null, null, null)?.use { cursor -> + val cursor = contentResolver.query(stableUri, null, null, null, null) + Timber.d("syncMessage: stableUri=$stableUri cursorCount=${cursor?.count}") + if (cursor == null) { + Timber.w("syncMessage: query returned null cursor for $stableUri") + return null + } + + return cursor.use { // If there are no rows, return null. Otherwise, we've moved to the first row - if (!cursor.moveToFirst()) return null + if (!cursor.moveToFirst()) { + Timber.w("syncMessage: cursor is empty for $stableUri") + return null + } val columnsMap = CursorToMessage.MessageColumns(cursor) cursorToMessage.map(Pair(cursor, columnsMap)).apply { existingId?.let { this.id = it } if (isMms()) { + val partsCursor = cursorToPart.getPartsCursor(contentId) + Timber.d("syncMessage: MMS contentId=$contentId partsCursorCount=${partsCursor?.count}") parts = RealmList().apply { - addAll(cursorToPart.getPartsCursor(contentId)?.map { cursorToPart.map(it) }.orEmpty()) + addAll(partsCursor?.map { cursorToPart.map(it) }.orEmpty()) } + Timber.d("syncMessage: loaded ${parts.size} parts: ${parts.map { "${it.type}(id=${it.id})" }}") } conversationRepo.getOrCreateConversation(threadId) insertOrUpdate() + Timber.d("syncMessage: saved message id=$id type=$type threadId=$threadId") val text = getText(false) val parsedReaction = reactions.parseEmojiReaction(text) diff --git a/data/src/test/java/org/prauga/messages/receiver/SmsReceiverTest.kt b/data/src/test/java/org/prauga/messages/receiver/SmsReceiverTest.kt index 86a9cc8f3..6e6896966 100644 --- a/data/src/test/java/org/prauga/messages/receiver/SmsReceiverTest.kt +++ b/data/src/test/java/org/prauga/messages/receiver/SmsReceiverTest.kt @@ -5,6 +5,7 @@ package org.prauga.messages.receiver import android.content.Context import android.content.Intent +import org.prauga.messages.interactor.ReceiveSms import org.prauga.messages.repository.MessageRepository import org.junit.Before import org.junit.Test @@ -24,6 +25,9 @@ class SmsReceiverTest { @Mock private lateinit var messageRepository: MessageRepository + @Mock + private lateinit var receiveSms: ReceiveSms + private lateinit var receiver: SmsReceiver @Before @@ -32,6 +36,7 @@ class SmsReceiverTest { receiver = SmsReceiver() // Manually inject dependencies for testing receiver.messageRepo = messageRepository + receiver.receiveSms = receiveSms } @Test @@ -92,14 +97,6 @@ class SmsReceiverTest { org.junit.Assert.assertNotEquals(sim2, noSim) } - @Test - fun givenWorkerInputDataKey_whenAccessed_thenIsCorrectValue() { - val expectedKey = "messageId" - - org.junit.Assert.assertEquals(expectedKey, - org.prauga.messages.worker.ReceiveSmsWorker.Companion.INPUT_DATA_KEY_MESSAGE_ID) - } - @Test fun givenEmptyMessageList_whenReduced_thenHandlesGracefully() { val emptyList = emptyList() 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/manager/NotificationManager.kt b/domain/src/main/java/com/moez/QKSMS/manager/NotificationManager.kt index 825a2b921..6dc84d2d1 100644 --- a/domain/src/main/java/com/moez/QKSMS/manager/NotificationManager.kt +++ b/domain/src/main/java/com/moez/QKSMS/manager/NotificationManager.kt @@ -34,4 +34,6 @@ interface NotificationManager { fun cancel(i: Int) + fun showDeleteConfirmation(threadId: Long) + } diff --git a/domain/src/main/java/com/moez/QKSMS/model/SearchItem.kt b/domain/src/main/java/com/moez/QKSMS/model/SearchItem.kt new file mode 100644 index 000000000..ba7ea70aa --- /dev/null +++ b/domain/src/main/java/com/moez/QKSMS/model/SearchItem.kt @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2025 Saalim Quadri + */ + +package org.prauga.messages.model + +sealed class SearchItem { + data class Header( + val conversationId: Long, + val title: String + ) : SearchItem() + + data class Message( + val messageId: Long, + val conversationId: Long, + val body: String, + val timestamp: Long + ) : SearchItem() +} 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 0cef17a80..67eaa5e20 100644 --- a/domain/src/main/java/com/moez/QKSMS/repository/ConversationRepository.kt +++ b/domain/src/main/java/com/moez/QKSMS/repository/ConversationRepository.kt @@ -44,6 +44,8 @@ interface ConversationRepository { fun searchConversations(query: CharSequence): List + fun searchConversationsGrouped(query: CharSequence): List + fun getBlockedConversations(): RealmResults fun getBlockedConversationsAsync(): RealmResults 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/presentation/proguard-rules.pro b/presentation/proguard-rules.pro index dd3caedf3..7c248a9f6 100644 --- a/presentation/proguard-rules.pro +++ b/presentation/proguard-rules.pro @@ -7,6 +7,7 @@ # ez-vcard -dontwarn ezvcard.** +-keep class ezvcard.Ezvcard { *; } -dontwarn org.apache.log.** -dontwarn org.apache.log4j.** -dontwarn org.python.core.** diff --git a/presentation/src/main/AndroidManifest.xml b/presentation/src/main/AndroidManifest.xml index 228120864..edea7bd4b 100644 --- a/presentation/src/main/AndroidManifest.xml +++ b/presentation/src/main/AndroidManifest.xml @@ -141,6 +141,7 @@ + + Timber.w(e, "Undeliverable RxJava exception") + } + // rxdogtag provides 'look-back' for exceptions in rxjava2 'chains' RxDogTag.builder() .configureWith(AutoDisposeConfigurer::configure) 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 c4f2da8f6..ec7dd2ddd 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 @@ -48,6 +48,7 @@ import org.prauga.messages.manager.PermissionManager import org.prauga.messages.manager.ShortcutManager import org.prauga.messages.mapper.CursorToPartImpl import org.prauga.messages.receiver.BlockThreadReceiver +import org.prauga.messages.receiver.DeleteConfirmationReceiver import org.prauga.messages.receiver.DeleteMessagesReceiver import org.prauga.messages.receiver.MarkArchivedReceiver import org.prauga.messages.receiver.MarkReadReceiver @@ -302,12 +303,11 @@ class NotificationManagerImpl @Inject constructor( } Preferences.NOTIFICATION_ACTION_DELETE -> { - val messageIds = messages.map { it.id }.toLongArray() - val intent = Intent(context, DeleteMessagesReceiver::class.java) + val intent = Intent(context, DeleteConfirmationReceiver::class.java) .putExtra("threadId", threadId) - .putExtra("messageIds", messageIds) + .putExtra("action", "show_confirmation") val pi = PendingIntent.getBroadcast( - context, threadId.toInt(), intent, + context, threadId.toInt() + 3000, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) NotificationCompat.Action.Builder( @@ -416,7 +416,10 @@ class NotificationManagerImpl @Inject constructor( .forEach { notification.addAction(it) } // Detect OTP in the latest message and add copy button if found + // If OTP is detected, show copy button instead of delete button val latestMessage = messages.lastOrNull() + var isOtpMessage = false + if (latestMessage != null) { val messageText = latestMessage.getText() val resourceProvider = OtpResourceProviderImpl(context) @@ -424,6 +427,7 @@ class NotificationManagerImpl @Inject constructor( val otpResult = otpDetector.detect(messageText) if (otpResult.isOtp && otpResult.code != null) { + isOtpMessage = true val copyOtpIntent = Intent(context, CopyOtpReceiver::class.java) .putExtra("otpCode", otpResult.code) val copyOtpPI = PendingIntent.getBroadcast( @@ -442,6 +446,26 @@ class NotificationManagerImpl @Inject constructor( } } + // Add delete button only for non-OTP messages + if (!isOtpMessage) { + val messageIds = messages.map { it.id }.toLongArray() + val deleteIntent = Intent(context, DeleteConfirmationReceiver::class.java) + .putExtra("threadId", threadId) + .putExtra("action", "show_confirmation") + val deletePI = PendingIntent.getBroadcast( + context, threadId.toInt() + 2000, deleteIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + val deleteAction = NotificationCompat.Action.Builder( + R.drawable.ic_delete_white_24dp, + context.getString(R.string.button_delete), + deletePI + ) + .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_DELETE) + .build() + notification.addAction(deleteAction) + } + if (prefs.qkreply.get()) { notification.priority = NotificationCompat.PRIORITY_DEFAULT @@ -674,4 +698,79 @@ class NotificationManagerImpl @Inject constructor( notificationManager.cancel(i) } + override fun showDeleteConfirmation(threadId: Long) { + val messages = messageRepo.getUnreadUnseenMessages(threadId) + if (messages.isEmpty()) { + return + } + + val conversation = conversationRepo.getConversation(threadId) ?: return + val lastRecipient = conversation.lastMessage?.let { lastMessage -> + conversation.recipients.find { recipient -> + phoneNumberUtils.compare(recipient.address, lastMessage.address) + } + } ?: conversation.recipients.firstOrNull() + + val contentIntent = Intent(context, ComposeActivity::class.java).putExtra("threadId", threadId) + val taskStackBuilder = TaskStackBuilder.create(context) + .addParentStack(ComposeActivity::class.java) + .addNextIntent(contentIntent) + val contentPI = taskStackBuilder.getPendingIntent( + threadId.toInt(), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val seenIntent = Intent(context, MarkSeenReceiver::class.java).putExtra("threadId", threadId) + val seenPI = PendingIntent.getBroadcast( + context, threadId.toInt(), seenIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + // Create Yes button (confirm delete) + val messageIds = messages.map { it.id }.toLongArray() + val confirmDeleteIntent = Intent(context, DeleteMessagesReceiver::class.java) + .putExtra("threadId", threadId) + .putExtra("messageIds", messageIds) + val confirmDeletePI = PendingIntent.getBroadcast( + context, threadId.toInt() + 4000, confirmDeleteIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + val yesAction = NotificationCompat.Action.Builder( + R.drawable.ic_check_white_24dp, + context.getString(R.string.button_yes), + confirmDeletePI + ).build() + + // Create No button (cancel delete) + val cancelDeleteIntent = Intent(context, DeleteConfirmationReceiver::class.java) + .putExtra("threadId", threadId) + .putExtra("action", "cancel") + val cancelDeletePI = PendingIntent.getBroadcast( + context, threadId.toInt() + 5000, cancelDeleteIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + val noAction = NotificationCompat.Action.Builder( + R.drawable.ic_close_black_24dp, + context.getString(R.string.button_cancel), + cancelDeletePI + ).build() + + val notification = NotificationCompat.Builder(context, getChannelIdForNotification(threadId)) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setColor(colors.theme(lastRecipient).theme) + .setPriority(NotificationCompat.PRIORITY_MAX) + .setSmallIcon(R.drawable.ic_notification) + .setAutoCancel(true) + .setContentIntent(contentPI) + .setDeleteIntent(seenPI) + .setContentTitle(context.getString(R.string.delete_confirmation_title)) + .setContentText(context.resources.getQuantityString( + R.plurals.delete_confirmation_message, messages.size, messages.size + )) + .addAction(yesAction) + .addAction(noAction) + + notificationManager.notify(threadId.toInt(), notification.build()) + } + } diff --git a/presentation/src/main/java/com/moez/QKSMS/common/widget/FastScrollerView.kt b/presentation/src/main/java/com/moez/QKSMS/common/widget/FastScrollerView.kt new file mode 100644 index 000000000..12501b912 --- /dev/null +++ b/presentation/src/main/java/com/moez/QKSMS/common/widget/FastScrollerView.kt @@ -0,0 +1,191 @@ +/* + * Copyright (C) 2025 Saalim Quadri + */ + +package com.moez.QKSMS.common.widget + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.widget.FrameLayout +import android.widget.TextView +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import org.prauga.messages.R +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +class FastScrollerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr) { + + interface SectionTitleProvider { + fun getSectionTitle(position: Int): String + } + + private var recyclerView: RecyclerView? = null + private var thumb: View + private var popup: TextView + + private var isDragging = false + private var hidePopupRunnable = Runnable { popup.visibility = View.GONE } + + // --- OPTIMIZATION CACHES --- + private var lastTargetPos = -1 + private var lastOffset = -1 + private var lastPopupPos = -1 + private var cachedItemHeight = 0 + private var cachedMaxTargetPos = 0 + + init { + LayoutInflater.from(context).inflate(R.layout.fast_scroller_view, this, true) + thumb = findViewById(R.id.fast_scroller_thumb) + popup = findViewById(R.id.fast_scroller_popup) + popup.visibility = View.GONE + } + + fun setupWithRecyclerView(recyclerView: RecyclerView) { + this.recyclerView = recyclerView + + recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) { + if (!isDragging) { + updateThumbPosition() + } + } + }) + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + updateThumbPosition() + } + + private fun updateThumbPosition() { + val rv = recyclerView ?: return + val extent = rv.computeVerticalScrollExtent() + val range = rv.computeVerticalScrollRange() + val offset = rv.computeVerticalScrollOffset() + + if (range <= extent || range == 0) { + thumb.visibility = View.GONE + return + } + + thumb.visibility = View.VISIBLE + val proportion = offset.toFloat() / (range - extent).toFloat() + val maxThumbY = height - thumb.height + val safeProportion = if (proportion.isNaN()) 0f else min(max(0f, proportion), 1f) + thumb.y = safeProportion * maxThumbY + } + + override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { + if (ev.action == MotionEvent.ACTION_DOWN) { + val touchSlop = width - (48 * resources.displayMetrics.density) + if (ev.x >= touchSlop && thumb.visibility == View.VISIBLE) { + return true + } + } + return super.onInterceptTouchEvent(ev) + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + val rv = recyclerView ?: return super.onTouchEvent(event) + + when (event.action) { + MotionEvent.ACTION_DOWN -> { + val touchSlop = width - (48 * resources.displayMetrics.density) + if (event.x >= touchSlop) { + isDragging = true + thumb.isPressed = true + removeCallbacks(hidePopupRunnable) + + // Pre-calculate expensive math once when dragging starts + prepareScrollMath(rv) + updateScrollAndPopup(event.y) + return true + } + } + MotionEvent.ACTION_MOVE -> { + if (isDragging) { + updateScrollAndPopup(event.y) + return true + } + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + if (isDragging) { + isDragging = false + thumb.isPressed = false + postDelayed(hidePopupRunnable, 500) + + // Reset caches + lastTargetPos = -1 + lastOffset = -1 + lastPopupPos = -1 + return true + } + } + } + return super.onTouchEvent(event) + } + + private fun prepareScrollMath(rv: RecyclerView) { + val adapter = rv.adapter ?: return + val itemCount = adapter.itemCount + + // Grab height from the first visible view safely + val firstView = rv.getChildAt(0) + cachedItemHeight = firstView?.height?.takeIf { it > 0 } ?: (72 * resources.displayMetrics.density).toInt() + + val visibleItemCount = max(1, rv.height / cachedItemHeight) + cachedMaxTargetPos = max(0, itemCount - visibleItemCount) + } + + private fun updateScrollAndPopup(y: Float) { + val rv = recyclerView ?: return + val layoutManager = rv.layoutManager as? LinearLayoutManager ?: return + val adapter = rv.adapter ?: return + if (cachedMaxTargetPos <= 0) return + + // 1. Move the thumb visually + val maxThumbY = height - thumb.height + val newY = min(max(0f, y - thumb.height / 2f), maxThumbY.toFloat()) + thumb.y = newY + val proportion = newY / maxThumbY + + // 2. Calculate smooth position + val exactPosition = proportion * cachedMaxTargetPos + val targetPos = exactPosition.toInt().coerceIn(0, cachedMaxTargetPos) + val subItemFraction = exactPosition - targetPos + val offset = -(subItemFraction * cachedItemHeight).toInt() + + // 3. ONLY trigger a layout pass if the position or offset actually changed + // This prevents the UI thread from being spammed with redundant draw calls + if (targetPos != lastTargetPos || abs(offset - lastOffset) > 1) { + layoutManager.scrollToPositionWithOffset(targetPos, offset) + lastTargetPos = targetPos + lastOffset = offset + } + + // 4. Update Date Modal Y-position constantly so it tracks the thumb perfectly + val maxPopupY = height - popup.height + popup.y = min(max(0f, newY + thumb.height / 2f - popup.height / 2f), maxPopupY.toFloat()) + + // 5. ONLY re-format the date text if we crossed into a new item + if (targetPos != lastPopupPos && adapter is SectionTitleProvider) { + val title = adapter.getSectionTitle(targetPos) + if (title.isNotEmpty()) { + popup.text = title + if (popup.visibility != View.VISIBLE) popup.visibility = View.VISIBLE + } else { + popup.visibility = View.GONE + } + lastPopupPos = targetPos + } + } +} 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/ComposeActivityModule.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeActivityModule.kt index fd8c99ee5..2a6a70b6d 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeActivityModule.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeActivityModule.kt @@ -41,6 +41,11 @@ class ComposeActivityModule { fun provideThreadId(activity: ComposeActivity): Long = activity.intent.extras?.getLong("threadId") ?: 0L + @Provides + @Named("messageId") + fun provideMessageId(activity: ComposeActivity): Long = + activity.intent.extras?.getLong("messageId") ?: 0L + @Provides @Named("addresses") fun provideAddresses(activity: ComposeActivity): List = diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeAttachmentAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeAttachmentAdapter.kt index 93eef6943..926db515e 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeAttachmentAdapter.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeAttachmentAdapter.kt @@ -79,9 +79,11 @@ class ComposeAttachmentAdapter @Inject constructor( ).first().getDisplayName() ?: "" binding.name.text = displayName binding.name.isVisible = displayName.isNotEmpty() - } catch (e: Exception) { - // npe from Ezvcard first() call above can be thrown if resource bytes cannot - // be retrieved from contact resource provider + } catch (e: Throwable) { + // ExceptionInInitializerError (an Error, not Exception) can be thrown if + // Ezvcard fails to load its properties file. NullPointerException from + // Ezvcard first() call can also be thrown if resource bytes cannot be + // retrieved from contact resource provider. binding.vCardAvatar.setImageResource(android.R.drawable.ic_delete) binding.name.text = context.getString(R.string.attachment_missing) binding.name.isVisible = true 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..198a92d3a 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 @@ -92,12 +93,14 @@ import timber.log.Timber import java.text.SimpleDateFormat import java.util.Locale import java.util.UUID +import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Named class ComposeViewModel @Inject constructor( @Named("query") private val query: String, @Named("threadId") private val threadId: Long, + @Named("messageId") private val messageId: Long, @Named("addresses") private val addresses: List, @Named("text") private val sharedText: String, @Named("attachments") val sharedAttachments: List, @@ -116,6 +119,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, @@ -312,6 +316,19 @@ class ComposeViewModel @Inject constructor( return } + // Scroll to specific message if messageId was provided + if (messageId != 0L) { + disposables += messages + .filter { it.isNotEmpty() } + .take(1) + .delay(300, TimeUnit.MILLISECONDS) // Small delay to ensure UI is ready + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(view.scope()) + .subscribe { + view.scrollToMessage(messageId) + } + } + val sharing = (sharedText.isNotEmpty() || sharedAttachments.isNotEmpty()) if (shouldShowContacts) { shouldShowContacts = false @@ -495,6 +512,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 +746,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 +760,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 29ae7bc80..d80aa776e 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,8 +72,10 @@ 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 timber.log.Timber import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Provider @@ -153,6 +155,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 +213,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 +248,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 } @@ -388,11 +405,11 @@ class MessagesAdapter @Inject constructor( binding.body.apply { setTextColor(incomingTextColor) setBackgroundTint(incomingBubbleColor) - highlightColor = incomingBubbleColor.withAlpha(0x5d) + highlightColor = incomingTextColor.withAlpha(0x55) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - textSelectHandle?.setTint(incomingBubbleColor.withAlpha(0x7d)) - textSelectHandleLeft?.setTint(incomingBubbleColor.withAlpha(0x7d)) - textSelectHandleRight?.setTint(incomingBubbleColor.withAlpha(0x7d)) + textSelectHandle?.setTint(incomingTextColor.withAlpha(0x7d)) + textSelectHandleLeft?.setTint(incomingTextColor.withAlpha(0x7d)) + textSelectHandleRight?.setTint(incomingTextColor.withAlpha(0x7d)) } } } else { @@ -402,11 +419,11 @@ class MessagesAdapter @Inject constructor( binding.body.apply { setTextColor(outgoingTextColor) setBackgroundTint(outgoingBubbleColor) - highlightColor = outgoingBubbleColor.withAlpha(0x5d) + highlightColor = outgoingTextColor.withAlpha(0x55) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - textSelectHandle?.setTint(outgoingBubbleColor.withAlpha(0xad)) - textSelectHandleLeft?.setTint(outgoingBubbleColor.withAlpha(0xad)) - textSelectHandleRight?.setTint(outgoingBubbleColor.withAlpha(0xad)) + textSelectHandle?.setTint(outgoingTextColor.withAlpha(0xad)) + textSelectHandleLeft?.setTint(outgoingTextColor.withAlpha(0xad)) + textSelectHandleRight?.setTint(outgoingTextColor.withAlpha(0xad)) } } } @@ -474,6 +491,7 @@ class MessagesAdapter @Inject constructor( } // Bind the parts + Timber.d("MessagesAdapter.bindMessage: messageId=${message.id} contentId=${message.contentId} isMms=${message.isMms()} partsCount=${message.parts.size} parts=${message.parts.map { "${it.type}(id=${it.id})" }}") binding.parts.adapter = partsAdapterProvider.get().apply { this.theme = theme setData(message, previous, next, holder, audioState) @@ -489,21 +507,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/compose/part/ImageBinder.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/part/ImageBinder.kt index bc5e4e8d3..13406b9c9 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/part/ImageBinder.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/part/ImageBinder.kt @@ -30,6 +30,7 @@ import org.prauga.messages.extensions.isVideo import org.prauga.messages.model.Message import org.prauga.messages.model.MmsPart import org.prauga.messages.util.GlideApp +import timber.log.Timber import javax.inject.Inject class ImageBinder @Inject constructor(colors: Colors, private val context: Context) : PartBinder() { @@ -58,7 +59,9 @@ class ImageBinder @Inject constructor(colors: Colors, private val context: Conte else -> BubbleImageView.Style.ONLY } - GlideApp.with(context).load(part.getUri()).fitCenter().into(binding.thumbnail) + val partUri = part.getUri() + Timber.d("ImageBinder.bindPart: partId=${part.id} type=${part.type} uri=$partUri messageId=${message.id}") + GlideApp.with(context).load(partUri).fitCenter().into(binding.thumbnail) } } \ No newline at end of file diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/part/PartsAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/part/PartsAdapter.kt index db458f86d..f7f17c1a5 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/part/PartsAdapter.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/part/PartsAdapter.kt @@ -32,6 +32,7 @@ import org.prauga.messages.feature.compose.BubbleUtils.canGroup import org.prauga.messages.feature.compose.MessagesAdapter import org.prauga.messages.model.Message import org.prauga.messages.model.MmsPart +import timber.log.Timber import javax.inject.Inject @@ -71,7 +72,9 @@ class PartsAdapter @Inject constructor( this.next = next this.bodyVisible = holder.containerView.findViewById(R.id.body)?.visibility == View.VISIBLE - this.data = message.parts.filter { !it.isSmil() && !it.isText() } + val filteredParts = message.parts.filter { !it.isSmil() && !it.isText() } + Timber.d("PartsAdapter.setData: messageId=${message.id} contentId=${message.contentId} totalParts=${message.parts.size} filteredParts=${filteredParts.size} types=${filteredParts.map { it.type }}") + this.data = filteredParts this.audioState = audioState } diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/conversations/ConversationsAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/conversations/ConversationsAdapter.kt index 07b16c13f..f3bd68072 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/conversations/ConversationsAdapter.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/conversations/ConversationsAdapter.kt @@ -25,6 +25,7 @@ import android.view.ViewGroup import androidx.core.text.buildSpannedString import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView +import com.moez.QKSMS.common.widget.FastScrollerView import org.prauga.messages.R import org.prauga.messages.common.Navigator import org.prauga.messages.common.base.QkRealmAdapter @@ -47,7 +48,7 @@ class ConversationsAdapter @Inject constructor( private val scheduledMessageRepo: ScheduledMessageRepository, private val navigator: Navigator, private val phoneNumberUtils: PhoneNumberUtils -) : QkRealmAdapter() { +) : QkRealmAdapter(), FastScrollerView.SectionTitleProvider { private val disposables = CompositeDisposable() init { @@ -159,4 +160,14 @@ class ConversationsAdapter @Inject constructor( } } + override fun getSectionTitle(position: Int): String { + val conversation = getItem(position) ?: return "" + val timestamp = conversation.date + return if (timestamp > 0) { + dateFormatter.getConversationTimestamp(timestamp) ?: "" + } else { + "" + } + } + } diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/gallery/GalleryPagerAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/gallery/GalleryPagerAdapter.kt index c9c4f5fe4..f0d9c6c51 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/gallery/GalleryPagerAdapter.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/gallery/GalleryPagerAdapter.kt @@ -62,23 +62,8 @@ class GalleryPagerAdapter @Inject constructor(private val context: Context) : when (viewType) { VIEW_TYPE_IMAGE -> { val binding = GalleryImagePageBinding.inflate(inflater, parent, false) - // When calling the public setter, it doesn't allow the midscale to be the same as the - // maxscale or the minscale. We don't want 3 levels and we don't want to modify the library - // so let's celebrate the invention of reflection! - binding.image.attacher.run { - javaClass.getDeclaredField("mMinScale").run { - isAccessible = true - setFloat(binding.image.attacher, 1f) - } - javaClass.getDeclaredField("mMidScale").run { - isAccessible = true - setFloat(binding.image.attacher, 1f) - } - javaClass.getDeclaredField("mMaxScale").run { - isAccessible = true - setFloat(binding.image.attacher, 3f) - } - } + // Use a tiny offset for midScale since setScaleLevels requires min < mid < max + binding.image.attacher.setScaleLevels(1f, 1.01f, 3f) binding.root.apply { setOnClickListener(clicks::onNext) } } diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/main/MainActivity.kt b/presentation/src/main/java/com/moez/QKSMS/feature/main/MainActivity.kt index b7593364b..4f3b38c94 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/main/MainActivity.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/main/MainActivity.kt @@ -210,6 +210,13 @@ class MainActivity : QkThemedActivity(MainActivityBinding:: itemTouchCallback.adapter = conversationsAdapter conversationsAdapter.autoScrollToStart(binding.recyclerView) + // Setup search adapter click listener + searchAdapter.onMessageClickListener = { conversationId, messageId -> + navigator.showConversation(conversationId, messageId) + } + + binding.fastScroller.setupWithRecyclerView(binding.recyclerView) + binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { super.onScrolled(recyclerView, dx, dy) @@ -403,6 +410,7 @@ class MainActivity : QkThemedActivity(MainActivityBinding:: if (binding.recyclerView.adapter !== searchAdapter) binding.recyclerView.adapter = searchAdapter searchAdapter.data = state.page.data ?: listOf() + searchAdapter.setQuery(binding.toolbarSearch.text.toString()) itemTouchHelper.attachToRecyclerView(null) binding.empty.setText(R.string.inbox_search_empty_text) } diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/main/MainState.kt b/presentation/src/main/java/com/moez/QKSMS/feature/main/MainState.kt index 8a6403d4d..75e3ab911 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/main/MainState.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/main/MainState.kt @@ -22,6 +22,7 @@ package org.prauga.messages.feature.main import io.realm.RealmResults import org.prauga.messages.model.Conversation import org.prauga.messages.model.SearchResult +import org.prauga.messages.model.SearchItem import org.prauga.messages.repository.SyncRepository data class MainState( @@ -51,7 +52,7 @@ data class Inbox( data class Searching( val loading: Boolean = false, - val data: List? = null + val data: List? = null ) : MainPage() data class Archived( 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 34abe45bf..d3c8ef3f0 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 @@ -52,6 +52,7 @@ import org.prauga.messages.manager.ChangelogManager import org.prauga.messages.manager.PermissionManager import org.prauga.messages.manager.RatingManager import org.prauga.messages.model.EmojiSyncNeeded +import org.prauga.messages.model.SearchItem import org.prauga.messages.model.SearchResult import org.prauga.messages.model.SyncLog import org.prauga.messages.repository.ConversationRepository @@ -307,13 +308,13 @@ class MainViewModel @Inject constructor( } } } - Observable.empty>() + Observable.empty>() } else { newState { val page = (page as? Searching) ?: Searching() copy(page = page.copy(loading = true)) } - Observable.fromCallable { conversationRepo.searchConversations(query) } + Observable.fromCallable { conversationRepo.searchConversationsGrouped(query) } .subscribeOn(Schedulers.io()) } } diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/main/SearchAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/main/SearchAdapter.kt index 14ad42136..bbb6a8503 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/main/SearchAdapter.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/main/SearchAdapter.kt @@ -24,16 +24,15 @@ import android.text.Spanned import android.text.style.BackgroundColorSpan import android.view.LayoutInflater import android.view.ViewGroup -import org.prauga.messages.R -import org.prauga.messages.common.Navigator +import androidx.recyclerview.widget.RecyclerView import org.prauga.messages.common.base.QkAdapter -import org.prauga.messages.common.base.QkBindingViewHolder import org.prauga.messages.common.util.Colors import org.prauga.messages.common.util.DateFormatter -import org.prauga.messages.common.util.extensions.setVisible -import org.prauga.messages.databinding.SearchListItemBinding +import org.prauga.messages.databinding.SearchItemHeaderBinding +import org.prauga.messages.databinding.SearchItemMessageBinding import org.prauga.messages.extensions.removeAccents -import org.prauga.messages.model.SearchResult +import org.prauga.messages.model.SearchItem +import org.prauga.messages.repository.ConversationRepository import javax.inject.Inject import kotlin.math.abs import kotlin.math.min @@ -42,68 +41,115 @@ class SearchAdapter @Inject constructor( colors: Colors, private val context: Context, private val dateFormatter: DateFormatter, - private val navigator: Navigator -) : QkAdapter>() { + private val conversationRepo: ConversationRepository +) : QkAdapter() { + + companion object { + private const val VIEW_TYPE_HEADER = 0 + private const val VIEW_TYPE_MESSAGE = 1 + } private val highlightColor: Int by lazy { colors.theme().highlight } + private var query: String = "" + var onMessageClickListener: ((conversationId: Long, messageId: Long) -> Unit)? = null - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int - ): QkBindingViewHolder { - val binding = - SearchListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return QkBindingViewHolder(binding).apply { - itemView.setOnClickListener { - val result = getItem(adapterPosition) - navigator.showConversation( - result.conversation.id, - result.query.takeIf { result.messages > 0 }) - } + fun setQuery(query: String) { + this.query = query + } + + override fun getItemViewType(position: Int): Int { + return when (getItem(position)) { + is SearchItem.Header -> VIEW_TYPE_HEADER + is SearchItem.Message -> VIEW_TYPE_MESSAGE } } - override fun onBindViewHolder( - holder: QkBindingViewHolder, - position: Int - ) { - val previous = data.getOrNull(position - 1) - val result = getItem(position) + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(parent.context) + return when (viewType) { + VIEW_TYPE_HEADER -> { + val binding = SearchItemHeaderBinding.inflate(inflater, parent, false) + HeaderViewHolder(binding) + } + VIEW_TYPE_MESSAGE -> { + val binding = SearchItemMessageBinding.inflate(inflater, parent, false) + MessageViewHolder(binding) + } + else -> throw IllegalArgumentException("Unknown view type: $viewType") + } + } - holder.binding.resultsHeader.setVisible(result.messages > 0 && previous?.messages == 0) + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (val item = getItem(position)) { + is SearchItem.Header -> (holder as HeaderViewHolder).bind(item) + is SearchItem.Message -> (holder as MessageViewHolder).bind(item) + } + } - val query = result.query - holder.binding.title.text = highlightText(result.conversation.getTitle(), query) + override fun areItemsTheSame(old: SearchItem, new: SearchItem): Boolean { + return when { + old is SearchItem.Header && new is SearchItem.Header -> + old.conversationId == new.conversationId + old is SearchItem.Message && new is SearchItem.Message -> + old.messageId == new.messageId + else -> false + } + } - holder.binding.avatars.recipients = result.conversation.recipients + override fun areContentsTheSame(old: SearchItem, new: SearchItem): Boolean { + return when { + old is SearchItem.Header && new is SearchItem.Header -> + old.title == new.title + old is SearchItem.Message && new is SearchItem.Message -> + old.body == new.body && old.timestamp == new.timestamp + else -> false + } + } - when (result.messages == 0) { - true -> { - holder.binding.date.setVisible(true) - holder.binding.date.text = dateFormatter.getConversationTimestamp(result.conversation.date) - val snippetText = when (result.conversation.me) { - true -> context.getString(R.string.main_sender_you, result.conversation.snippet) - false -> result.conversation.snippet + inner class HeaderViewHolder( + private val binding: SearchItemHeaderBinding + ) : RecyclerView.ViewHolder(binding.root) { + + fun bind(header: SearchItem.Header) { + binding.headerTitle.text = highlightText(header.title, query) + + // Get conversation to access recipients for avatar and phone number + val conversation = conversationRepo.getConversation(header.conversationId) + conversation?.let { + binding.headerAvatar.recipients = it.recipients + + // Show phone number(s) as subtitle + val phoneNumbers = it.recipients.joinToString(", ") { recipient -> + recipient.address } - holder.binding.snippet.text = highlightText(snippetText ?: "", query) - } - - false -> { - holder.binding.date.setVisible(false) - holder.binding.snippet.text = - context.getString(R.string.main_message_results, result.messages) + binding.headerSubtitle.text = phoneNumbers } } } - override fun areItemsTheSame(old: SearchResult, new: SearchResult): Boolean { - return old.conversation.id == new.conversation.id && old.messages > 0 == new.messages > 0 - } + inner class MessageViewHolder( + private val binding: SearchItemMessageBinding + ) : RecyclerView.ViewHolder(binding.root) { + + init { + binding.root.setOnClickListener { + val position = bindingAdapterPosition + if (position != RecyclerView.NO_POSITION) { + val item = getItem(position) + if (item is SearchItem.Message) { + onMessageClickListener?.invoke(item.conversationId, item.messageId) + } + } + } + } - override fun areContentsTheSame(old: SearchResult, new: SearchResult): Boolean { - return old.query == new.query && // Queries are the same - old.conversation.id == new.conversation.id // Conversation id is the same - && old.messages == new.messages // Result count is the same + fun bind(message: SearchItem.Message) { + binding.messageBody.text = highlightText(message.body, query) + // Use getConversationTimestamp for date (no time included) + binding.messageDate.text = dateFormatter.getConversationTimestamp(message.timestamp) + // Use getTimestamp for time only + binding.messageTime.text = dateFormatter.getTimestamp(message.timestamp) + } } private fun highlightText(text: CharSequence, query: CharSequence): SpannableString { 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 176a61695..fe9b283fc 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 @@ -25,6 +25,7 @@ import org.prauga.messages.injection.scope.ActivityScope import org.prauga.messages.receiver.BlockThreadReceiver import org.prauga.messages.receiver.BootReceiver import org.prauga.messages.receiver.DefaultSmsChangedReceiver +import org.prauga.messages.receiver.DeleteConfirmationReceiver import org.prauga.messages.receiver.DeleteMessagesReceiver import org.prauga.messages.receiver.MarkArchivedReceiver import org.prauga.messages.receiver.MarkReadReceiver @@ -62,6 +63,10 @@ abstract class BroadcastReceiverBuilderModule { @ContributesAndroidInjector() abstract fun bindDeleteMessagesReceiver(): DeleteMessagesReceiver + @ActivityScope + @ContributesAndroidInjector() + abstract fun bindDeleteConfirmationReceiver(): DeleteConfirmationReceiver + @ActivityScope @ContributesAndroidInjector abstract fun bindMarkArchivedReceiver(): MarkArchivedReceiver diff --git a/presentation/src/main/java/com/moez/QKSMS/receiver/DeleteConfirmationReceiver.kt b/presentation/src/main/java/com/moez/QKSMS/receiver/DeleteConfirmationReceiver.kt new file mode 100644 index 000000000..d7e3f03ad --- /dev/null +++ b/presentation/src/main/java/com/moez/QKSMS/receiver/DeleteConfirmationReceiver.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2025 Saalim Quadri + */ + +package org.prauga.messages.receiver + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import dagger.android.AndroidInjection +import org.prauga.messages.manager.NotificationManager +import javax.inject.Inject + +class DeleteConfirmationReceiver : BroadcastReceiver() { + + @Inject lateinit var notificationManager: NotificationManager + + override fun onReceive(context: Context, intent: Intent) { + AndroidInjection.inject(this, context) + + val threadId = intent.getLongExtra("threadId", 0) + val action = intent.getStringExtra("action") ?: return + + when (action) { + "show_confirmation" -> { + // Update notification to show confirmation + notificationManager.showDeleteConfirmation(threadId) + } + "cancel" -> { + // Restore original notification + notificationManager.update(threadId) + } + } + } +} diff --git a/presentation/src/main/res/drawable/fast_scroller_popup_bg.xml b/presentation/src/main/res/drawable/fast_scroller_popup_bg.xml new file mode 100644 index 000000000..31c2ad06e --- /dev/null +++ b/presentation/src/main/res/drawable/fast_scroller_popup_bg.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/presentation/src/main/res/drawable/fast_scroller_thumb_selector.xml b/presentation/src/main/res/drawable/fast_scroller_thumb_selector.xml new file mode 100644 index 000000000..1e2918961 --- /dev/null +++ b/presentation/src/main/res/drawable/fast_scroller_thumb_selector.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + diff --git a/presentation/src/main/res/layout/fast_scroller_view.xml b/presentation/src/main/res/layout/fast_scroller_view.xml new file mode 100644 index 000000000..0ea863199 --- /dev/null +++ b/presentation/src/main/res/layout/fast_scroller_view.xml @@ -0,0 +1,34 @@ + + + + + + + + + diff --git a/presentation/src/main/res/layout/main_activity.xml b/presentation/src/main/res/layout/main_activity.xml index d7e186ffd..4a899c19e 100644 --- a/presentation/src/main/res/layout/main_activity.xml +++ b/presentation/src/main/res/layout/main_activity.xml @@ -256,6 +256,18 @@ app:layout_constraintTop_toBottomOf="@id/filterGroup" tools:listitem="@layout/conversation_list_item" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/presentation/src/main/res/layout/search_item_header.xml b/presentation/src/main/res/layout/search_item_header.xml new file mode 100644 index 000000000..490106f79 --- /dev/null +++ b/presentation/src/main/res/layout/search_item_header.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + diff --git a/presentation/src/main/res/layout/search_item_message.xml b/presentation/src/main/res/layout/search_item_message.xml new file mode 100644 index 000000000..be413407c --- /dev/null +++ b/presentation/src/main/res/layout/search_item_message.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + 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 @@ -259,6 +267,11 @@ Schedule a message Schedule this message Scheduled message + + Send now + Copy text + Delete + Missing Appearance @@ -488,6 +501,13 @@ Stop More Set + + Delete + + Are you sure you would like to delete this message? + Are you sure you would like to delete %d messages? + + Undo Clear