diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3eea3fbf..b669ba54 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,6 +11,7 @@ + @@ -212,6 +213,10 @@ + + ) { - val privateCursor = getMyContactsCursor(false, true) + val privateCursor = getMyContactsCursor(favoritesOnly = false, withPhoneNumbersOnly = true) ensureBackgroundThread { val privateContacts = MyContactsContentProvider.getSimpleContacts(this, privateCursor) val conversations = getConversations(privateContacts = privateContacts) - runOnUiThread { - setupConversations(conversations) - } - conversations.forEach { clonedConversation -> - if (!cachedConversations.map { it.threadId }.contains(clonedConversation.threadId)) { + val threadIds = cachedConversations.map { it.threadId } + if (!threadIds.contains(clonedConversation.threadId)) { conversationsDB.insertOrUpdate(clonedConversation) cachedConversations.add(clonedConversation) } } cachedConversations.forEach { cachedConversation -> - if (!conversations.map { it.threadId }.contains(cachedConversation.threadId)) { - conversationsDB.deleteThreadId(cachedConversation.threadId) + val threadId = cachedConversation.threadId + + val isTemporaryThread = cachedConversation.isScheduled + val isConversationDeleted = !conversations.map { it.threadId }.contains(threadId) + if (isConversationDeleted && !isTemporaryThread) { + conversationsDB.deleteThreadId(threadId) + } + + val newConversation = conversations.find { it.phoneNumber == cachedConversation.phoneNumber } + if (isTemporaryThread && newConversation != null) { + // delete the original temporary thread and move any scheduled messages to the new thread + conversationsDB.deleteThreadId(threadId) + messagesDB.getScheduledThreadMessages(threadId) + .forEach { message -> + messagesDB.insertOrUpdate(message.copy(threadId = newConversation.threadId)) + } } } - cachedConversations.forEach { cachedConversation -> - val conv = conversations.firstOrNull { it.threadId == cachedConversation.threadId && it.toString() != cachedConversation.toString() } + cachedConversations.forEach { cachedConv -> + val conv = conversations.find { it.threadId == cachedConv.threadId && !cachedConv.areContentsTheSame(it) } if (conv != null) { - conversationsDB.insertOrUpdate(conv) + val conversation = conv.copy(date = maxOf(cachedConv.date, conv.date)) + conversationsDB.insertOrUpdate(conversation) } } + val allConversations = conversationsDB.getAll() as ArrayList + runOnUiThread { + setupConversations(allConversations) + } + if (config.appRunCount == 1) { conversations.map { it.threadId }.forEach { threadId -> - val messages = getMessages(threadId, false) + val messages = getMessages(threadId, getImageResolutions = false, includeScheduledMessages = false) messages.chunked(30).forEach { currentMessages -> messagesDB.insertMessages(*currentMessages.toTypedArray()) } @@ -273,8 +292,9 @@ class MainActivity : SimpleActivity() { hideKeyboard() ConversationsAdapter(this, sortedConversations, conversations_list) { Intent(this, ThreadActivity::class.java).apply { - putExtra(THREAD_ID, (it as Conversation).threadId) - putExtra(THREAD_TITLE, it.title) + val conversation = it as Conversation + putExtra(THREAD_ID, conversation.threadId) + putExtra(THREAD_TITLE, conversation.title) startActivity(this) } }.apply { @@ -313,7 +333,7 @@ class MainActivity : SimpleActivity() { val manager = getSystemService(ShortcutManager::class.java) try { - manager.dynamicShortcuts = Arrays.asList(newConversation) + manager.dynamicShortcuts = listOf(newConversation) config.lastHandledShortcutColor = appIconColor } catch (ignored: Exception) { } diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/activities/ThreadActivity.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/activities/ThreadActivity.kt index 59c66ebd..1b5ebe1d 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/activities/ThreadActivity.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/activities/ThreadActivity.kt @@ -13,10 +13,16 @@ import android.os.Bundle import android.provider.ContactsContract import android.provider.MediaStore import android.provider.Telephony +import android.provider.Telephony.Sms.MESSAGE_TYPE_QUEUED +import android.provider.Telephony.Sms.STATUS_NONE import android.telephony.SmsManager import android.telephony.SmsMessage import android.telephony.SubscriptionInfo import android.text.TextUtils +import android.text.format.DateUtils +import android.text.format.DateUtils.FORMAT_NO_YEAR +import android.text.format.DateUtils.FORMAT_SHOW_DATE +import android.text.format.DateUtils.FORMAT_SHOW_TIME import android.util.TypedValue import android.view.Gravity import android.view.View @@ -25,6 +31,7 @@ import android.view.inputmethod.EditorInfo import android.widget.LinearLayout import android.widget.LinearLayout.LayoutParams import android.widget.RelativeLayout +import androidx.core.content.res.ResourcesCompat import com.bumptech.glide.Glide import com.bumptech.glide.load.DataSource import com.bumptech.glide.load.engine.DiskCacheStrategy @@ -37,8 +44,6 @@ import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.target.Target import com.google.gson.Gson import com.google.gson.reflect.TypeToken -import com.klinker.android.send_message.Transaction -import com.klinker.android.send_message.Utils.getNumPages import com.simplemobiletools.commons.dialogs.ConfirmationDialog import com.simplemobiletools.commons.dialogs.RadioGroupDialog import com.simplemobiletools.commons.extensions.* @@ -50,17 +55,17 @@ import com.simplemobiletools.commons.views.MyRecyclerView import com.simplemobiletools.smsmessenger.R import com.simplemobiletools.smsmessenger.adapters.AutoCompleteTextViewAdapter import com.simplemobiletools.smsmessenger.adapters.ThreadAdapter +import com.simplemobiletools.smsmessenger.dialogs.ScheduleSendDialog import com.simplemobiletools.smsmessenger.extensions.* import com.simplemobiletools.smsmessenger.helpers.* import com.simplemobiletools.smsmessenger.models.* -import com.simplemobiletools.smsmessenger.receivers.SmsStatusDeliveredReceiver -import com.simplemobiletools.smsmessenger.receivers.SmsStatusSentReceiver import kotlinx.android.synthetic.main.activity_thread.* import kotlinx.android.synthetic.main.item_attachment.view.* import kotlinx.android.synthetic.main.item_selected_contact.view.* import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode +import org.joda.time.DateTime import java.io.File import java.io.InputStream import java.io.OutputStream @@ -74,6 +79,10 @@ class ThreadActivity : SimpleActivity() { private val TYPE_TAKE_PHOTO = 12 private val TYPE_CHOOSE_PHOTO = 13 + private val TYPE_EDIT = 14 + private val TYPE_SEND = 15 + private val TYPE_DELETE = 16 + private var threadId = 0L private var currentSIMCardIndex = 0 private var isActivityVisible = false @@ -92,6 +101,10 @@ class ThreadActivity : SimpleActivity() { private var allMessagesFetched = false private var oldestMessageDate = -1 + private var isScheduledMessage: Boolean = false + private var scheduledMessage: Message? = null + private lateinit var scheduledDateTime: DateTime + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_thread) @@ -227,6 +240,8 @@ class ThreadActivity : SimpleActivity() { } catch (e: Exception) { ArrayList() } + clearExpiredScheduledMessages(threadId, messages) + messages.removeAll { it.isScheduled && it.millis() < System.currentTimeMillis() } messages.sortBy { it.date } if (messages.size > MESSAGES_LIMIT) { @@ -258,8 +273,8 @@ class ThreadActivity : SimpleActivity() { val cachedMessagesCode = messages.clone().hashCode() messages = getMessages(threadId, true) - val hasParticipantWithoutName = participants.any { - it.phoneNumbers.map { it.normalizedNumber }.contains(it.name) + val hasParticipantWithoutName = participants.any { contact -> + contact.phoneNumbers.map { it.normalizedNumber }.contains(contact.name) } try { @@ -325,11 +340,13 @@ class ThreadActivity : SimpleActivity() { val currAdapter = thread_messages_list.adapter if (currAdapter == null) { - ThreadAdapter(this, threadItems, thread_messages_list) { - (it as? ThreadError)?.apply { - thread_type_message.setText(it.messageText) - } - }.apply { + ThreadAdapter( + activity = this, + messages = threadItems, + recyclerView = thread_messages_list, + itemClick = { handleItemClick(it) }, + onThreadIdUpdate = { threadId = it } + ).apply { thread_messages_list.adapter = this } @@ -371,6 +388,13 @@ class ThreadActivity : SimpleActivity() { } } + private fun handleItemClick(any: Any) { + when { + any is Message && any.isScheduled -> showScheduledMessageInfo(any) + any is ThreadError -> thread_type_message.setText(any.messageText) + } + } + private fun fetchNextMessages() { if (messages.isEmpty() || allMessagesFetched || loadingOlderMessages) { return @@ -425,6 +449,12 @@ class ThreadActivity : SimpleActivity() { thread_send_message.setOnClickListener { sendMessage() } + thread_send_message.setOnLongClickListener { + if (!isScheduledMessage) { + launchScheduleSendDialog() + } + true + } thread_send_message.isClickable = false thread_type_message.onTextChangeListener { @@ -468,6 +498,8 @@ class ThreadActivity : SimpleActivity() { addAttachment(it) } } + + setupScheduleSendUi() } private fun setupAttachmentSizes() { @@ -580,8 +612,7 @@ class ThreadActivity : SimpleActivity() { val defaultSmsSubscriptionId = SmsManager.getDefaultSmsSubscriptionId() val systemPreferredSimIdx = if (defaultSmsSubscriptionId >= 0) { - val defaultSmsSIM = subscriptionManagerCompat().getActiveSubscriptionInfo(defaultSmsSubscriptionId) - availableSIMs.indexOfFirstOrNull { it.subscriptionId == defaultSmsSIM.subscriptionId } + availableSIMs.indexOfFirstOrNull { it.subscriptionId == defaultSmsSubscriptionId } } else { null } @@ -590,13 +621,7 @@ class ThreadActivity : SimpleActivity() { } private fun blockNumber() { - val numbers = ArrayList() - participants.forEach { - it.phoneNumbers.forEach { - numbers.add(it.normalizedNumber) - } - } - + val numbers = participants.getAddresses() val numbersString = TextUtils.join(", ", numbers) val question = String.format(resources.getString(R.string.block_confirmation), numbersString) @@ -909,9 +934,11 @@ class ThreadActivity : SimpleActivity() { private fun checkSendMessageAvailability() { if (thread_type_message.text!!.isNotEmpty() || (attachmentSelections.isNotEmpty() && !attachmentSelections.values.any { it.isPending })) { + thread_send_message.isEnabled = true thread_send_message.isClickable = true thread_send_message.alpha = 0.9f } else { + thread_send_message.isEnabled = false thread_send_message.isClickable = false thread_send_message.alpha = 0.4f } @@ -919,57 +946,69 @@ class ThreadActivity : SimpleActivity() { } private fun sendMessage() { - var msg = thread_type_message.value - if (msg.isEmpty() && attachmentSelections.isEmpty()) { + var text = thread_type_message.value + if (text.isEmpty() && attachmentSelections.isEmpty()) { showErrorToast(getString(R.string.unknown_error_occurred)) return } - msg = removeDiacriticsIfNeeded(msg) + text = removeDiacriticsIfNeeded(text) - val numbers = ArrayList() - participants.forEach { contact -> - contact.phoneNumbers.forEach { - numbers.add(it.normalizedNumber) - } + val subscriptionId = availableSIMCards.getOrNull(currentSIMCardIndex)?.subscriptionId ?: SmsManager.getDefaultSmsSubscriptionId() + + if (isScheduledMessage) { + sendScheduledMessage(text, subscriptionId) + } else { + sendNormalMessage(text, subscriptionId) + } + } + + private fun sendScheduledMessage(text: String, subscriptionId: Int) { + if (scheduledDateTime.millis < System.currentTimeMillis() + 1000L) { + toast(R.string.must_pick_time_in_the_future) + launchScheduleSendDialog(scheduledDateTime) + return } - val settings = getSendMessageSettings() - val currentSubscriptionId = availableSIMCards.getOrNull(currentSIMCardIndex)?.subscriptionId - if (currentSubscriptionId != null) { - settings.subscriptionId = currentSubscriptionId - } - - val transaction = Transaction(this, settings) - val message = com.klinker.android.send_message.Message(msg, numbers.toTypedArray()) - - if (attachmentSelections.isNotEmpty()) { - for (selection in attachmentSelections.values) { - try { - val byteArray = contentResolver.openInputStream(selection.uri)?.readBytes() ?: continue - val mimeType = contentResolver.getType(selection.uri) ?: continue - message.addMedia(byteArray, mimeType) - } catch (e: Exception) { - showErrorToast(e) - } catch (e: Error) { - showErrorToast(e.localizedMessage ?: getString(R.string.unknown_error_occurred)) + refreshedSinceSent = false + try { + ensureBackgroundThread { + val messageId = scheduledMessage?.id ?: generateRandomId() + val message = buildScheduledMessage(text, subscriptionId, messageId) + if (messages.isEmpty()) { + // create a temporary thread until a real message is sent + threadId = message.threadId + createTemporaryThread(message, message.threadId) } + messagesDB.insertOrUpdate(message) + val conversation = conversationsDB.getConversationWithThreadId(threadId) + if (conversation != null) { + val nowSeconds = (System.currentTimeMillis() / 1000).toInt() + conversationsDB.insertOrUpdate(conversation.copy(date = nowSeconds)) + } + scheduleMessage(message) } + clearCurrentMessage() + hideScheduleSendUi() + scheduledMessage = null + + if (!refreshedSinceSent) { + refreshMessages() + } + } catch (e: Exception) { + showErrorToast(e.localizedMessage ?: getString(R.string.unknown_error_occurred)) } + } + + private fun sendNormalMessage(text: String, subscriptionId: Int) { + val addresses = participants.getAddresses() + val attachments = attachmentSelections.values + .map { it.uri } try { - val smsSentIntent = Intent(this, SmsStatusSentReceiver::class.java) - val deliveredIntent = Intent(this, SmsStatusDeliveredReceiver::class.java) - - transaction.setExplicitBroadcastForSentSms(smsSentIntent) - transaction.setExplicitBroadcastForDeliveredSms(deliveredIntent) - refreshedSinceSent = false - transaction.sendNewMessage(message) - thread_type_message.setText("") - attachmentSelections.clear() - thread_attachments_holder.beGone() - thread_attachments_wrapper.removeAllViews() + sendMessage(text, addresses, subscriptionId, attachments) + clearCurrentMessage() if (!refreshedSinceSent) { refreshMessages() @@ -981,6 +1020,13 @@ class ThreadActivity : SimpleActivity() { } } + private fun clearCurrentMessage() { + thread_type_message.setText("") + attachmentSelections.clear() + thread_attachments_holder.beGone() + thread_attachments_wrapper.removeAllViews() + } + // show selected contacts, properly split to new lines when appropriate // based on https://stackoverflow.com/a/13505029/1967672 private fun showSelectedContact(views: ArrayList) { @@ -997,16 +1043,16 @@ class ThreadActivity : SimpleActivity() { var isFirstRow = true for (i in views.indices) { - val LL = LinearLayout(this) - LL.orientation = LinearLayout.HORIZONTAL - LL.gravity = Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM - LL.layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + val layout = LinearLayout(this) + layout.orientation = LinearLayout.HORIZONTAL + layout.gravity = Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM + layout.layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) views[i].measure(0, 0) var params = LayoutParams(views[i].measuredWidth, LayoutParams.WRAP_CONTENT) params.setMargins(0, 0, mediumMargin, 0) - LL.addView(views[i], params) - LL.measure(0, 0) + layout.addView(views[i], params) + layout.measure(0, 0) widthSoFar += views[i].measuredWidth + mediumMargin val checkWidth = if (isFirstRow) firstRowWidth else parentWidth @@ -1016,15 +1062,15 @@ class ThreadActivity : SimpleActivity() { newLinearLayout = LinearLayout(this) newLinearLayout.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) newLinearLayout.orientation = LinearLayout.HORIZONTAL - params = LayoutParams(LL.measuredWidth, LL.measuredHeight) + params = LayoutParams(layout.measuredWidth, layout.measuredHeight) params.topMargin = mediumMargin - newLinearLayout.addView(LL, params) - widthSoFar = LL.measuredWidth + newLinearLayout.addView(layout, params) + widthSoFar = layout.measuredWidth } else { if (!isFirstRow) { - (LL.layoutParams as LayoutParams).topMargin = mediumMargin + (layout.layoutParams as LayoutParams).topMargin = mediumMargin } - newLinearLayout.addView(LL) + newLinearLayout.addView(layout) } } selected_contacts.addView(newLinearLayout) @@ -1125,16 +1171,26 @@ class ThreadActivity : SimpleActivity() { notificationManager.cancel(threadId.hashCode()) } - val lastMaxId = messages.maxByOrNull { it.id }?.id ?: 0L - messages = getMessages(threadId, true) + val newThreadId = getThreadId(participants.getAddresses().toSet()) + val newMessages = getMessages(newThreadId, false) + messages = if (messages.all { it.isScheduled } && newMessages.isNotEmpty()) { + threadId = newThreadId + // update scheduled messages with real thread id + updateScheduledMessagesThreadId(messages, newThreadId) + getMessages(newThreadId, true) + } else { + getMessages(threadId, true) + } + + val lastMaxId = messages.filterNot { it.isScheduled }.maxByOrNull { it.id }?.id ?: 0L messages.filter { !it.isReceivedMessage() && it.id > lastMaxId }.forEach { latestMessage -> // subscriptionIds seem to be not filled out at sending with multiple SIM cards, so fill it manually if ((subscriptionManagerCompat().activeSubscriptionInfoList?.size ?: 0) > 1) { - val SIMId = availableSIMCards.getOrNull(currentSIMCardIndex)?.subscriptionId - if (SIMId != null) { - updateMessageSubscriptionId(latestMessage.id, SIMId) - latestMessage.subscriptionId = SIMId + val subscriptionId = availableSIMCards.getOrNull(currentSIMCardIndex)?.subscriptionId + if (subscriptionId != null) { + updateMessageSubscriptionId(latestMessage.id, subscriptionId) + latestMessage.subscriptionId = subscriptionId } } @@ -1145,12 +1201,15 @@ class ThreadActivity : SimpleActivity() { setupSIMSelector() } - private fun updateMessageType() { - val settings = getSendMessageSettings() - val text = thread_type_message.text.toString() + private fun isMmsMessage(text: String): Boolean { val isGroupMms = participants.size > 1 && config.sendGroupMessageMMS - val isLongMmsMessage = getNumPages(settings, text) > settings.sendLongAsMmsAfter && config.sendLongMessageMMS - val stringId = if (attachmentSelections.isNotEmpty() || isGroupMms || isLongMmsMessage) { + val isLongMmsMessage = isLongMmsMessage(text) && config.sendLongMessageMMS + return attachmentSelections.isNotEmpty() || isGroupMms || isLongMmsMessage + } + + private fun updateMessageType() { + val text = thread_type_message.text.toString() + val stringId = if (isMmsMessage(text)) { R.string.mms } else { R.string.sms @@ -1166,4 +1225,143 @@ class ThreadActivity : SimpleActivity() { } return File.createTempFile("IMG_", ".jpg", outputDirectory) } + + private fun showScheduledMessageInfo(message: Message) { + val items = arrayListOf( + RadioItem(TYPE_EDIT, getString(R.string.update_message)), + RadioItem(TYPE_SEND, getString(R.string.send_now)), + RadioItem(TYPE_DELETE, getString(R.string.delete)) + ) + RadioGroupDialog(activity = this, items = items, titleId = R.string.scheduled_message) { + when (it as Int) { + TYPE_DELETE -> cancelScheduledMessageAndRefresh(message.id) + TYPE_EDIT -> editScheduledMessage(message) + TYPE_SEND -> { + extractAttachments(message) + sendNormalMessage(message.body, message.subscriptionId) + cancelScheduledMessageAndRefresh(message.id) + } + } + } + } + + private fun extractAttachments(message: Message) { + val messageAttachment = message.attachment + if (messageAttachment != null) { + for (attachment in messageAttachment.attachments) { + addAttachment(attachment.getUri()) + } + } + } + + private fun editScheduledMessage(message: Message) { + scheduledMessage = message + clearCurrentMessage() + thread_type_message.setText(message.body) + extractAttachments(message) + scheduledDateTime = DateTime(message.millis()) + showScheduleSendUi() + } + + private fun cancelScheduledMessageAndRefresh(messageId: Long) { + ensureBackgroundThread { + deleteScheduledMessage(messageId) + cancelScheduleSendPendingIntent(messageId) + refreshMessages() + } + } + + private fun launchScheduleSendDialog(originalDt: DateTime? = null) { + ScheduleSendDialog(this, originalDt) { newDt -> + if (newDt != null) { + scheduledDateTime = newDt + showScheduleSendUi() + } + } + } + + private fun setupScheduleSendUi() { + val textColor = getProperTextColor() + scheduled_message_holder.background.applyColorFilter(getProperBackgroundColor().getContrastColor()) + scheduled_message_button.apply { + val clockDrawable = ResourcesCompat.getDrawable(resources, R.drawable.ic_clock_vector, theme)?.apply { applyColorFilter(textColor) } + setCompoundDrawablesWithIntrinsicBounds(clockDrawable, null, null, null) + setTextColor(textColor) + setOnClickListener { + launchScheduleSendDialog(scheduledDateTime) + } + } + + discard_scheduled_message.apply { + applyColorFilter(textColor) + setOnClickListener { + hideScheduleSendUi() + if (scheduledMessage != null) { + cancelScheduledMessageAndRefresh(scheduledMessage!!.id) + scheduledMessage = null + } + } + } + } + + private fun showScheduleSendUi() { + isScheduledMessage = true + updateSendButtonDrawable() + scheduled_message_holder.beVisible() + + val dt = scheduledDateTime + val millis = dt.millis + scheduled_message_button.text = if (dt.yearOfCentury().get() > DateTime.now().yearOfCentury().get()) { + millis.formatDate(this) + } else { + val flags = FORMAT_SHOW_TIME or FORMAT_SHOW_DATE or FORMAT_NO_YEAR + DateUtils.formatDateTime(this, millis, flags) + } + } + + private fun hideScheduleSendUi() { + isScheduledMessage = false + scheduled_message_holder.beGone() + updateSendButtonDrawable() + } + + private fun updateSendButtonDrawable() { + val drawableResId = if (isScheduledMessage) { + R.drawable.ic_schedule_send_vector + } else { + R.drawable.ic_send_vector + } + ResourcesCompat.getDrawable(resources, drawableResId, theme)?.apply { + applyColorFilter(getProperTextColor()) + thread_send_message.setCompoundDrawablesWithIntrinsicBounds(null, this, null, null) + } + } + + private fun buildScheduledMessage(text: String, subscriptionId: Int, messageId: Long): Message { + val threadId = if (messages.isEmpty()) messageId else threadId + return Message( + id = messageId, + body = text, + type = MESSAGE_TYPE_QUEUED, + status = STATUS_NONE, + participants = participants, + date = (scheduledDateTime.millis / 1000).toInt(), + read = false, + threadId = threadId, + isMMS = isMmsMessage(text), + attachment = buildMessageAttachment(text, messageId), + senderName = "", + senderPhotoUri = "", + subscriptionId = subscriptionId, + isScheduled = true + ) + } + + private fun buildMessageAttachment(text: String, messageId: Long): MessageAttachment { + val attachments = attachmentSelections.values + .map { Attachment(null, messageId, it.uri.toString(), contentResolver.getType(it.uri) ?: "*/*", 0, 0, "") } + .toArrayList() + + return MessageAttachment(messageId, text, attachments) + } } diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/adapters/ConversationsAdapter.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/adapters/ConversationsAdapter.kt index 9a9a47bb..4613f0f1 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/adapters/ConversationsAdapter.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/adapters/ConversationsAdapter.kt @@ -173,7 +173,7 @@ class ConversationsAdapter( } try { - conversations.removeAll(conversationsToRemove) + conversations.removeAll(conversationsToRemove.toSet()) } catch (ignored: Exception) { } @@ -319,15 +319,16 @@ class ConversationsAdapter( setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize * 0.8f) } - if (conversation.read) { - conversation_address.setTypeface(null, Typeface.NORMAL) - conversation_body_short.setTypeface(null, Typeface.NORMAL) + val style = if (conversation.read) { conversation_body_short.alpha = 0.7f + if (conversation.isScheduled) Typeface.ITALIC else Typeface.NORMAL } else { - conversation_address.setTypeface(null, Typeface.BOLD) - conversation_body_short.setTypeface(null, Typeface.BOLD) conversation_body_short.alpha = 1f + if (conversation.isScheduled) Typeface.BOLD_ITALIC else Typeface.BOLD + } + conversation_address.setTypeface(null, style) + conversation_body_short.setTypeface(null, style) arrayListOf(conversation_address, conversation_body_short, conversation_date).forEach { it.setTextColor(textColor) diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/adapters/ThreadAdapter.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/adapters/ThreadAdapter.kt index 75eb264d..cace2918 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/adapters/ThreadAdapter.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/adapters/ThreadAdapter.kt @@ -4,10 +4,10 @@ import android.annotation.SuppressLint import android.content.ActivityNotFoundException import android.content.Intent import android.graphics.Color +import android.graphics.Typeface import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.net.Uri -import android.telephony.SubscriptionManager import android.util.TypedValue import android.view.Menu import android.view.View @@ -40,15 +40,21 @@ import com.simplemobiletools.smsmessenger.models.* import kotlinx.android.synthetic.main.item_attachment_image.view.* import kotlinx.android.synthetic.main.item_attachment_vcard.view.* import kotlinx.android.synthetic.main.item_received_message.view.* +import kotlinx.android.synthetic.main.item_received_message.view.thread_mesage_attachments_holder +import kotlinx.android.synthetic.main.item_received_message.view.thread_message_body +import kotlinx.android.synthetic.main.item_received_message.view.thread_message_holder +import kotlinx.android.synthetic.main.item_received_message.view.thread_message_play_outline import kotlinx.android.synthetic.main.item_received_unknown_attachment.view.* +import kotlinx.android.synthetic.main.item_sent_message.view.* import kotlinx.android.synthetic.main.item_sent_unknown_attachment.view.* import kotlinx.android.synthetic.main.item_thread_date_time.view.* import kotlinx.android.synthetic.main.item_thread_error.view.* import kotlinx.android.synthetic.main.item_thread_sending.view.* import kotlinx.android.synthetic.main.item_thread_success.view.* +import java.util.* class ThreadAdapter( - activity: SimpleActivity, var messages: ArrayList, recyclerView: MyRecyclerView, itemClick: (Any) -> Unit + activity: SimpleActivity, var messages: ArrayList, recyclerView: MyRecyclerView, itemClick: (Any) -> Unit, val onThreadIdUpdate: (Long) -> Unit ) : MyRecyclerViewAdapter(activity, recyclerView, itemClick) { private var fontSize = activity.getTextSize() @@ -199,11 +205,21 @@ class ThreadAdapter( messagesToRemove.forEach { activity.deleteMessage((it as Message).id, it.isMMS) } - messages.removeAll(messagesToRemove) + messages.removeAll(messagesToRemove.toSet()) activity.updateLastConversationMessage(threadId) + val messages = messages.filterIsInstance() + if (messages.isNotEmpty() && messages.all { it.isScheduled }) { + // move all scheduled messages to a temporary thread as there are no real messages left + val message = messages.last() + val newThreadId = generateRandomId() + activity.createTemporaryThread(message, newThreadId) + activity.updateScheduledMessagesThreadId(messages, newThreadId) + onThreadIdUpdate(newThreadId) + } + activity.runOnUiThread { - if (messages.filter { it is Message }.isEmpty()) { + if (messages.isEmpty()) { activity.finish() } else { removeSelectedItems(positions) @@ -252,29 +268,9 @@ class ThreadAdapter( thread_message_body.beVisibleIf(message.body.isNotEmpty()) if (message.isReceivedMessage()) { - thread_message_sender_photo.beVisible() - thread_message_sender_photo.setOnClickListener { - val contact = message.participants.first() - context.getContactFromAddress(contact.phoneNumbers.first().normalizedNumber) { - if (it != null) { - (activity as ThreadActivity).startContactDetailsIntent(it) - } - } - } - thread_message_body.setTextColor(textColor) - thread_message_body.setLinkTextColor(context.getProperPrimaryColor()) - - if (!activity.isFinishing && !activity.isDestroyed) { - SimpleContactsHelper(context).loadContactImage(message.senderPhotoUri, thread_message_sender_photo, message.senderName) - } + setupReceivedMessageView(view, message) } else { - thread_message_sender_photo?.beGone() - val background = context.getProperPrimaryColor() - thread_message_body.background.applyColorFilter(background) - - val contrastColor = background.getContrastColor() - thread_message_body.setTextColor(contrastColor) - thread_message_body.setLinkTextColor(contrastColor) + setupSentMessageView(view, message) } thread_message_body.setOnLongClickListener { @@ -304,6 +300,54 @@ class ThreadAdapter( } } + private fun setupReceivedMessageView(view: View, message: Message) { + view.apply { + thread_message_sender_photo.beVisible() + thread_message_sender_photo.setOnClickListener { + val contact = message.participants.first() + context.getContactFromAddress(contact.phoneNumbers.first().normalizedNumber) { + if (it != null) { + (activity as ThreadActivity).startContactDetailsIntent(it) + } + } + } + thread_message_body.setTextColor(textColor) + thread_message_body.setLinkTextColor(context.getProperPrimaryColor()) + + if (!activity.isFinishing && !activity.isDestroyed) { + SimpleContactsHelper(context).loadContactImage(message.senderPhotoUri, thread_message_sender_photo, message.senderName) + } + } + } + + private fun setupSentMessageView(view: View, message: Message) { + view.apply { + thread_message_sender_photo?.beGone() + val background = context.getProperPrimaryColor() + thread_message_body.background.applyColorFilter(background) + + val contrastColor = background.getContrastColor() + thread_message_body.setTextColor(contrastColor) + thread_message_body.setLinkTextColor(contrastColor) + + val padding = thread_message_body.paddingStart + if (message.isScheduled) { + thread_message_scheduled_icon.beVisible() + thread_message_scheduled_icon.applyColorFilter(contrastColor) + + val iconWidth = resources.getDimensionPixelSize(R.dimen.small_icon_size) + val rightPadding = padding + iconWidth + thread_message_body.setPadding(padding, padding, rightPadding, padding) + thread_message_body.typeface = Typeface.create(Typeface.DEFAULT, Typeface.ITALIC) + } else { + thread_message_scheduled_icon.beGone() + + thread_message_body.setPadding(padding, padding, padding, padding) + thread_message_body.typeface = Typeface.DEFAULT + } + } + } + private fun setupImageView(holder: ViewHolder, parent: View, message: Message, attachment: Attachment) { val mimetype = attachment.mimetype val uri = attachment.getUri() @@ -461,7 +505,7 @@ class ThreadAdapter( private fun launchViewIntent(uri: Uri, mimetype: String, filename: String) { Intent().apply { action = Intent.ACTION_VIEW - setDataAndType(uri, mimetype.toLowerCase()) + setDataAndType(uri, mimetype.lowercase(Locale.getDefault())) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) try { diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/databases/MessagesDatabase.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/databases/MessagesDatabase.kt index d4c4972b..72fda67f 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/databases/MessagesDatabase.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/databases/MessagesDatabase.kt @@ -17,7 +17,7 @@ import com.simplemobiletools.smsmessenger.models.Conversation import com.simplemobiletools.smsmessenger.models.Message import com.simplemobiletools.smsmessenger.models.MessageAttachment -@Database(entities = [Conversation::class, Attachment::class, MessageAttachment::class, Message::class], version = 4) +@Database(entities = [Conversation::class, Attachment::class, MessageAttachment::class, Message::class], version = 5) @TypeConverters(Converters::class) abstract class MessagesDatabase : RoomDatabase() { @@ -41,6 +41,7 @@ abstract class MessagesDatabase : RoomDatabase() { .addMigrations(MIGRATION_1_2) .addMigrations(MIGRATION_2_3) .addMigrations(MIGRATION_3_4) + .addMigrations(MIGRATION_4_5) .build() } } @@ -66,8 +67,10 @@ abstract class MessagesDatabase : RoomDatabase() { database.apply { execSQL("CREATE TABLE conversations_new (`thread_id` INTEGER NOT NULL PRIMARY KEY, `snippet` TEXT NOT NULL, `date` INTEGER NOT NULL, `read` INTEGER NOT NULL, `title` TEXT NOT NULL, `photo_uri` TEXT NOT NULL, `is_group_conversation` INTEGER NOT NULL, `phone_number` TEXT NOT NULL)") - execSQL("INSERT OR IGNORE INTO conversations_new (thread_id, snippet, date, read, title, photo_uri, is_group_conversation, phone_number) " + - "SELECT thread_id, snippet, date, read, title, photo_uri, is_group_conversation, phone_number FROM conversations") + execSQL( + "INSERT OR IGNORE INTO conversations_new (thread_id, snippet, date, read, title, photo_uri, is_group_conversation, phone_number) " + + "SELECT thread_id, snippet, date, read, title, photo_uri, is_group_conversation, phone_number FROM conversations" + ) execSQL("DROP TABLE conversations") @@ -85,5 +88,14 @@ abstract class MessagesDatabase : RoomDatabase() { } } } + + private val MIGRATION_4_5 = object : Migration(4, 5) { + override fun migrate(database: SupportSQLiteDatabase) { + database.apply { + execSQL("ALTER TABLE messages ADD COLUMN is_scheduled INTEGER NOT NULL DEFAULT 0") + execSQL("ALTER TABLE conversations ADD COLUMN is_scheduled INTEGER NOT NULL DEFAULT 0") + } + } + } } } diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/dialogs/ScheduleSendDialog.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/dialogs/ScheduleSendDialog.kt new file mode 100644 index 00000000..ddcf9506 --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/dialogs/ScheduleSendDialog.kt @@ -0,0 +1,155 @@ +package com.simplemobiletools.smsmessenger.dialogs + +import android.app.DatePickerDialog +import android.app.DatePickerDialog.OnDateSetListener +import android.app.TimePickerDialog +import android.app.TimePickerDialog.OnTimeSetListener +import android.text.format.DateFormat +import androidx.appcompat.app.AlertDialog +import com.simplemobiletools.commons.activities.BaseSimpleActivity +import com.simplemobiletools.commons.extensions.* +import com.simplemobiletools.smsmessenger.R +import com.simplemobiletools.smsmessenger.extensions.config +import com.simplemobiletools.smsmessenger.extensions.roundToClosestMultipleOf +import kotlinx.android.synthetic.main.schedule_message_dialog.view.* +import org.joda.time.DateTime +import java.util.* + +class ScheduleSendDialog(private val activity: BaseSimpleActivity, private var dateTime: DateTime? = null, private val callback: (dt: DateTime?) -> Unit) { + private val view = activity.layoutInflater.inflate(R.layout.schedule_message_dialog, null) + private val textColor = activity.getProperTextColor() + + private var previewDialog: AlertDialog? = null + private var previewShown = false + private var isNewMessage = dateTime == null + + private val calendar = Calendar.getInstance() + + init { + arrayOf(view.subtitle, view.edit_time, view.edit_date).forEach { it.setTextColor(textColor) } + arrayOf(view.dateIcon, view.timeIcon).forEach { it.applyColorFilter(textColor) } + view.edit_date.setOnClickListener { showDatePicker() } + view.edit_time.setOnClickListener { showTimePicker() } + updateTexts(dateTime ?: DateTime.now().plusHours(1)) + + if (isNewMessage) { + showDatePicker() + } else { + showPreview() + } + } + + private fun updateTexts(dt: DateTime) { + val dateFormat = activity.config.dateFormat + val timeFormat = activity.getTimeFormat() + view.edit_date.text = dt.toString(dateFormat) + view.edit_time.text = dt.toString(timeFormat) + } + + private fun showPreview() { + if (previewShown) return + activity.getAlertDialogBuilder() + .setPositiveButton(R.string.ok, null) + .setNegativeButton(R.string.cancel, null) + .apply { + previewShown = true + activity.setupDialogStuff(view, this, R.string.schedule_send) { dialog -> + previewDialog = dialog + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { + if (validateDateTime()) { + callback(dateTime) + dialog.dismiss() + } + } + dialog.setOnDismissListener { + previewShown = false + previewDialog = null + } + } + } + } + + private fun showDatePicker() { + val year = dateTime?.year ?: calendar.get(Calendar.YEAR) + val monthOfYear = dateTime?.monthOfYear?.minus(1) ?: calendar.get(Calendar.MONTH) + val dayOfMonth = dateTime?.dayOfMonth ?: calendar.get(Calendar.DAY_OF_MONTH) + + val dateSetListener = OnDateSetListener { _, y, m, d -> dateSet(y, m, d) } + DatePickerDialog( + activity, activity.getDatePickerDialogTheme(), dateSetListener, year, monthOfYear, dayOfMonth + ).apply { + datePicker.minDate = System.currentTimeMillis() + show() + getButton(AlertDialog.BUTTON_NEGATIVE).apply { + text = activity.getString(R.string.back) + setOnClickListener { + showPreview() + dismiss() + } + } + } + } + + private fun showTimePicker() { + val hourOfDay = dateTime?.hourOfDay ?: getNextHour() + val minute = dateTime?.minuteOfHour ?: getNextMinute() + + val timeSetListener = OnTimeSetListener { _, h, m -> timeSet(h, m) } + TimePickerDialog( + activity, activity.getDatePickerDialogTheme(), timeSetListener, hourOfDay, minute, DateFormat.is24HourFormat(activity) + ).apply { + show() + getButton(AlertDialog.BUTTON_NEGATIVE).apply { + text = activity.getString(R.string.back) + setOnClickListener { + showPreview() + dismiss() + } + } + } + } + + private fun dateSet(year: Int, monthOfYear: Int, dayOfMonth: Int) { + if (isNewMessage) { + showTimePicker() + } + + dateTime = DateTime.now() + .withDate(year, monthOfYear + 1, dayOfMonth) + .run { + if (dateTime != null) { + withTime(dateTime!!.hourOfDay, dateTime!!.minuteOfHour, 0, 0) + } else { + withTime(getNextHour(), getNextMinute(), 0, 0) + } + } + if (!isNewMessage) { + validateDateTime() + } + isNewMessage = false + updateTexts(dateTime!!) + } + + private fun timeSet(hourOfDay: Int, minute: Int) { + dateTime = dateTime?.withHourOfDay(hourOfDay)?.withMinuteOfHour(minute) + if (validateDateTime()) { + updateTexts(dateTime!!) + showPreview() + } else { + showTimePicker() + } + } + + private fun validateDateTime(): Boolean { + return if (dateTime?.isAfterNow == false) { + activity.toast(R.string.must_pick_time_in_the_future) + false + } else { + true + } + } + + private fun getNextHour() = (calendar.get(Calendar.HOUR_OF_DAY) + 1).coerceIn(0, 23) + + private fun getNextMinute() = (calendar.get(Calendar.MINUTE) + 5).roundToClosestMultipleOf(5).coerceIn(0, 59) +} diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/extensions/ArrayList.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/extensions/ArrayList.kt deleted file mode 100644 index dfaf3202..00000000 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/extensions/ArrayList.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.simplemobiletools.smsmessenger.extensions - -import android.text.TextUtils -import com.simplemobiletools.commons.models.SimpleContact - -fun ArrayList.getThreadTitle() = TextUtils.join(", ", map { it.name }.toTypedArray()) diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/extensions/Collections.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/extensions/Collections.kt index 38e3d1b2..2fdbaac4 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/extensions/Collections.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/extensions/Collections.kt @@ -30,3 +30,5 @@ fun Map.toContentValues(): ContentValues { return contentValues } + +fun Collection.toArrayList() = ArrayList(this) diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/extensions/Context.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/extensions/Context.kt index eccbf2ad..c285ec98 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/extensions/Context.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/extensions/Context.kt @@ -26,7 +26,6 @@ import android.telephony.SubscriptionManager import android.text.TextUtils import androidx.core.app.NotificationCompat import androidx.core.app.RemoteInput -import com.klinker.android.send_message.Settings import com.klinker.android.send_message.Transaction.getAddressSeparator import com.simplemobiletools.commons.extensions.* import com.simplemobiletools.commons.helpers.* @@ -58,7 +57,7 @@ val Context.messageAttachmentsDB: MessageAttachmentsDao get() = getMessagesDB(). val Context.messagesDB: MessagesDao get() = getMessagesDB().MessagesDao() -fun Context.getMessages(threadId: Long, getImageResolutions: Boolean, dateFrom: Int = -1): ArrayList { +fun Context.getMessages(threadId: Long, getImageResolutions: Boolean, dateFrom: Int = -1, includeScheduledMessages: Boolean = true): ArrayList { val uri = Sms.CONTENT_URI val projection = arrayOf( Sms._ID, @@ -117,8 +116,21 @@ fun Context.getMessages(threadId: Long, getImageResolutions: Boolean, dateFrom: } messages.addAll(getMMS(threadId, getImageResolutions, sortOrder)) - messages = messages.filter { it.participants.isNotEmpty() } - .sortedWith(compareBy { it.date }.thenBy { it.id }).toMutableList() as ArrayList + + if (includeScheduledMessages) { + try { + val scheduledMessages = messagesDB.getScheduledThreadMessages(threadId) + messages.addAll(scheduledMessages) + } catch (e: Exception) { + e.printStackTrace() + } + } + + messages = messages + .filter { it.participants.isNotEmpty() } + .filterNot { it.isScheduled && it.millis() < System.currentTimeMillis() } + .sortedWith(compareBy { it.date }.thenBy { it.id }) + .toMutableList() as ArrayList return messages } @@ -570,7 +582,11 @@ fun Context.deleteConversation(threadId: Long) { } uri = Mms.CONTENT_URI - contentResolver.delete(uri, selection, selectionArgs) + try { + contentResolver.delete(uri, selection, selectionArgs) + } catch (e: Exception) { + e.printStackTrace() + } conversationsDB.deleteThreadId(threadId) messagesDB.deleteThreadMessages(threadId) @@ -588,6 +604,14 @@ fun Context.deleteMessage(id: Long, isMMS: Boolean) { } } +fun Context.deleteScheduledMessage(messageId: Long) { + try { + messagesDB.delete(messageId) + } catch (e: Exception) { + showErrorToast(e) + } +} + fun Context.markMessageRead(id: Long, isMMS: Boolean) { val uri = if (isMMS) Mms.CONTENT_URI else Sms.CONTENT_URI val contentValues = ContentValues().apply { @@ -972,16 +996,6 @@ fun Context.getFileSizeFromUri(uri: Uri): Long { } } -fun Context.getSendMessageSettings(): Settings { - val settings = Settings() - settings.useSystemSending = true - settings.deliveryReports = config.enableDeliveryReports - settings.sendLongAsMms = config.sendLongMessageMMS - settings.sendLongAsMmsAfter = 1 - settings.group = config.sendGroupMessageMMS - return settings -} - // fix a glitch at enabling Release version minifying from 5.12.3 // reset messages in 5.14.3 again, as PhoneNumber is no longer minified fun Context.clearAllMessagesIfNeeded() { @@ -1001,3 +1015,52 @@ fun Context.subscriptionManagerCompat(): SubscriptionManager { SubscriptionManager.from(this) } } + +fun Context.createTemporaryThread(message: Message, threadId: Long = generateRandomId()) { + val simpleContactHelper = SimpleContactsHelper(this) + val addresses = message.participants.getAddresses() + val photoUri = if (addresses.size == 1) simpleContactHelper.getPhotoUriFromPhoneNumber(addresses.first()) else "" + + val conversation = Conversation( + threadId = threadId, + snippet = message.body, + date = message.date, + read = true, + title = message.participants.getThreadTitle(), + photoUri = photoUri, + isGroupConversation = addresses.size > 1, + phoneNumber = addresses.first(), + isScheduled = true + ) + try { + conversationsDB.insertOrUpdate(conversation) + } catch (e: Exception) { + e.printStackTrace() + } +} + +fun Context.updateScheduledMessagesThreadId(messages: List, newThreadId: Long) { + val scheduledMessages = messages.map { it.copy(threadId = newThreadId) }.toTypedArray() + messagesDB.insertMessages(*scheduledMessages) +} + +fun Context.clearExpiredScheduledMessages(threadId: Long, messagesToDelete: List? = null) { + val messages = messagesToDelete ?: messagesDB.getScheduledThreadMessages(threadId) + val now = System.currentTimeMillis() + 500L + + try { + messages.filter { it.isScheduled && it.millis() < now }.forEach { msg -> + messagesDB.delete(msg.id) + } + if (messages.filterNot { it.isScheduled && it.millis() < now }.isEmpty()) { + // delete empty temporary thread + val conversation = conversationsDB.getConversationWithThreadId(threadId) + if (conversation != null && conversation.isScheduled) { + conversationsDB.deleteThreadId(threadId) + } + } + } catch (e: Exception) { + e.printStackTrace() + return + } +} diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/extensions/Math.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/extensions/Math.kt new file mode 100644 index 00000000..17462d10 --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/extensions/Math.kt @@ -0,0 +1,8 @@ +package com.simplemobiletools.smsmessenger.extensions + +import kotlin.math.roundToInt + +/** + * Returns the closest number divisible by [multipleOf]. + */ +fun Int.roundToClosestMultipleOf(multipleOf: Int = 1) = (toDouble() / multipleOf).roundToInt() * multipleOf diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/extensions/SimpleContact.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/extensions/SimpleContact.kt new file mode 100644 index 00000000..2efa8e26 --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/extensions/SimpleContact.kt @@ -0,0 +1,8 @@ +package com.simplemobiletools.smsmessenger.extensions + +import android.text.TextUtils +import com.simplemobiletools.commons.models.SimpleContact + +fun ArrayList.getThreadTitle(): String = TextUtils.join(", ", map { it.name }.toTypedArray()).orEmpty() + +fun ArrayList.getAddresses() = flatMap { it.phoneNumbers }.map { it.normalizedNumber } diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/extensions/gson/JsonElement.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/extensions/gson/JsonElement.kt index 876d6353..2b2287fd 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/extensions/gson/JsonElement.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/extensions/gson/JsonElement.kt @@ -4,7 +4,6 @@ import com.google.gson.* import java.math.BigDecimal import java.math.BigInteger - val JsonElement.optString: String? get() = safeConversion { asString } diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/helpers/Constants.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/helpers/Constants.kt index 973e56bc..15baf492 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/helpers/Constants.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/helpers/Constants.kt @@ -29,11 +29,14 @@ const val IMPORT_SMS = "import_sms" const val IMPORT_MMS = "import_mms" const val WAS_DB_CLEARED = "was_db_cleared_2" const val EXTRA_VCARD_URI = "vcard" +const val SCHEDULED_MESSAGE_ID = "scheduled_message_id" private const val PATH = "com.simplemobiletools.smsmessenger.action." const val MARK_AS_READ = PATH + "mark_as_read" const val REPLY = PATH + "reply" +const val DATE_FORMAT_PATTERN = "dd MMM, YYYY" + // view types for the thread list view const val THREAD_DATE_TIME = 1 const val THREAD_RECEIVED_MESSAGE = 2 diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/helpers/Messaging.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/helpers/Messaging.kt new file mode 100644 index 00000000..fb048c6c --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/helpers/Messaging.kt @@ -0,0 +1,111 @@ +package com.simplemobiletools.smsmessenger.helpers + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Handler +import android.os.Looper +import androidx.core.app.AlarmManagerCompat +import com.klinker.android.send_message.Settings +import com.klinker.android.send_message.Transaction +import com.klinker.android.send_message.Utils +import com.simplemobiletools.commons.extensions.showErrorToast +import com.simplemobiletools.commons.helpers.isMarshmallowPlus +import com.simplemobiletools.smsmessenger.R +import com.simplemobiletools.smsmessenger.extensions.config +import com.simplemobiletools.smsmessenger.models.Message +import com.simplemobiletools.smsmessenger.receivers.ScheduledMessageReceiver +import com.simplemobiletools.smsmessenger.receivers.SmsStatusDeliveredReceiver +import com.simplemobiletools.smsmessenger.receivers.SmsStatusSentReceiver +import org.joda.time.DateTime +import org.joda.time.DateTimeZone +import kotlin.math.abs +import kotlin.random.Random + +fun Context.getSendMessageSettings(): Settings { + val settings = Settings() + settings.useSystemSending = true + settings.deliveryReports = config.enableDeliveryReports + settings.sendLongAsMms = config.sendLongMessageMMS + settings.sendLongAsMmsAfter = 1 + settings.group = config.sendGroupMessageMMS + return settings +} + +fun Context.sendMessage(text: String, addresses: List, subscriptionId: Int?, attachments: List) { + val settings = getSendMessageSettings() + if (subscriptionId != null) { + settings.subscriptionId = subscriptionId + } + + val transaction = Transaction(this, settings) + val message = com.klinker.android.send_message.Message(text, addresses.toTypedArray()) + + if (attachments.isNotEmpty()) { + for (uri in attachments) { + try { + val byteArray = contentResolver.openInputStream(uri)?.readBytes() ?: continue + val mimeType = contentResolver.getType(uri) ?: continue + message.addMedia(byteArray, mimeType) + } catch (e: Exception) { + showErrorToast(e) + } catch (e: Error) { + showErrorToast(e.localizedMessage ?: getString(R.string.unknown_error_occurred)) + } + } + } + + val smsSentIntent = Intent(this, SmsStatusSentReceiver::class.java) + val deliveredIntent = Intent(this, SmsStatusDeliveredReceiver::class.java) + + transaction.setExplicitBroadcastForSentSms(smsSentIntent) + transaction.setExplicitBroadcastForDeliveredSms(deliveredIntent) + Handler(Looper.getMainLooper()).post { + transaction.sendNewMessage(message) + } +} + +fun Context.getScheduleSendPendingIntent(message: Message): PendingIntent { + val intent = Intent(this, ScheduledMessageReceiver::class.java) + intent.putExtra(THREAD_ID, message.threadId) + intent.putExtra(SCHEDULED_MESSAGE_ID, message.id) + + var flags = PendingIntent.FLAG_UPDATE_CURRENT + if (isMarshmallowPlus()) { + flags = flags or PendingIntent.FLAG_IMMUTABLE + } + + return PendingIntent.getBroadcast(this, message.id.toInt(), intent, flags) +} + +fun Context.scheduleMessage(message: Message) { + val pendingIntent = getScheduleSendPendingIntent(message) + val triggerAtMillis = message.millis() + + val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager + AlarmManagerCompat.setExactAndAllowWhileIdle(alarmManager, AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent) +} + +fun Context.cancelScheduleSendPendingIntent(messageId: Long) { + val intent = Intent(this, ScheduledMessageReceiver::class.java) + var flags = PendingIntent.FLAG_UPDATE_CURRENT + if (isMarshmallowPlus()) { + flags = flags or PendingIntent.FLAG_IMMUTABLE + } + + PendingIntent.getBroadcast(this, messageId.toInt(), intent, flags).cancel() +} + +fun Context.isLongMmsMessage(text: String): Boolean { + val settings = getSendMessageSettings() + return Utils.getNumPages(settings, text) > settings.sendLongAsMmsAfter +} + +/** Not to be used with real messages persisted in the telephony db. This is for internal use only (e.g. scheduled messages). */ +fun generateRandomId(length: Int = 9): Long { + val millis = DateTime.now(DateTimeZone.UTC).millis + val random = abs(Random(millis).nextLong()) + return random.toString().takeLast(length).toLong() +} diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/interfaces/ConversationsDao.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/interfaces/ConversationsDao.kt index 534cdc1f..0c8db25c 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/interfaces/ConversationsDao.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/interfaces/ConversationsDao.kt @@ -14,6 +14,9 @@ interface ConversationsDao { @Query("SELECT * FROM conversations") fun getAll(): List + @Query("SELECT * FROM conversations WHERE thread_id = :threadId") + fun getConversationWithThreadId(threadId: Long): Conversation? + @Query("SELECT * FROM conversations WHERE read = 0") fun getUnreadConversations(): List diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/interfaces/MessagesDao.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/interfaces/MessagesDao.kt index 0ddf5b59..7036167b 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/interfaces/MessagesDao.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/interfaces/MessagesDao.kt @@ -23,6 +23,12 @@ interface MessagesDao { @Query("SELECT * FROM messages WHERE thread_id = :threadId") fun getThreadMessages(threadId: Long): List + @Query("SELECT * FROM messages WHERE thread_id = :threadId AND is_scheduled = 1") + fun getScheduledThreadMessages(threadId: Long): List + + @Query("SELECT * FROM messages WHERE thread_id = :threadId AND id = :messageId AND is_scheduled = 1") + fun getScheduledMessageWithId(threadId: Long, messageId: Long): Message + @Query("SELECT * FROM messages WHERE body LIKE :text") fun getMessagesWithText(text: String): List diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/models/Conversation.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/models/Conversation.kt index fc5698f4..86d09777 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/models/Conversation.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/models/Conversation.kt @@ -14,5 +14,17 @@ data class Conversation( @ColumnInfo(name = "title") var title: String, @ColumnInfo(name = "photo_uri") var photoUri: String, @ColumnInfo(name = "is_group_conversation") var isGroupConversation: Boolean, - @ColumnInfo(name = "phone_number") var phoneNumber: String -) + @ColumnInfo(name = "phone_number") var phoneNumber: String, + @ColumnInfo(name = "is_scheduled") var isScheduled: Boolean = false +) { + + fun areContentsTheSame(other: Conversation): Boolean { + return snippet == other.snippet && + date == other.date && + read == other.read && + title == other.title && + photoUri == other.photoUri && + isGroupConversation == other.isGroupConversation && + phoneNumber == other.phoneNumber + } +} diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/models/Message.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/models/Message.kt index da89136e..48a25679 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/models/Message.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/models/Message.kt @@ -20,7 +20,11 @@ data class Message( @ColumnInfo(name = "attachment") val attachment: MessageAttachment?, @ColumnInfo(name = "sender_name") var senderName: String, @ColumnInfo(name = "sender_photo_uri") val senderPhotoUri: String, - @ColumnInfo(name = "subscription_id") var subscriptionId: Int) : ThreadItem() { + @ColumnInfo(name = "subscription_id") var subscriptionId: Int, + @ColumnInfo(name = "is_scheduled") var isScheduled: Boolean = false +) : ThreadItem() { fun isReceivedMessage() = type == Telephony.Sms.MESSAGE_TYPE_INBOX + + fun millis() = date * 1000L } diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/receivers/DirectReplyReceiver.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/receivers/DirectReplyReceiver.kt index caac5115..81114a0f 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/receivers/DirectReplyReceiver.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/receivers/DirectReplyReceiver.kt @@ -4,7 +4,6 @@ import android.annotation.SuppressLint import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import android.telephony.SubscriptionManager import androidx.core.app.RemoteInput import com.klinker.android.send_message.Transaction import com.simplemobiletools.commons.extensions.notificationManager @@ -14,6 +13,7 @@ import com.simplemobiletools.smsmessenger.extensions.* import com.simplemobiletools.smsmessenger.helpers.REPLY import com.simplemobiletools.smsmessenger.helpers.THREAD_ID import com.simplemobiletools.smsmessenger.helpers.THREAD_NUMBER +import com.simplemobiletools.smsmessenger.helpers.getSendMessageSettings class DirectReplyReceiver : BroadcastReceiver() { @SuppressLint("MissingPermission") diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/receivers/ScheduledMessageReceiver.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/receivers/ScheduledMessageReceiver.kt new file mode 100644 index 00000000..bc89a283 --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/receivers/ScheduledMessageReceiver.kt @@ -0,0 +1,58 @@ +package com.simplemobiletools.smsmessenger.receivers + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.PowerManager +import com.simplemobiletools.commons.extensions.showErrorToast +import com.simplemobiletools.commons.helpers.ensureBackgroundThread +import com.simplemobiletools.smsmessenger.R +import com.simplemobiletools.smsmessenger.extensions.conversationsDB +import com.simplemobiletools.smsmessenger.extensions.deleteScheduledMessage +import com.simplemobiletools.smsmessenger.extensions.getAddresses +import com.simplemobiletools.smsmessenger.extensions.messagesDB +import com.simplemobiletools.smsmessenger.helpers.SCHEDULED_MESSAGE_ID +import com.simplemobiletools.smsmessenger.helpers.THREAD_ID +import com.simplemobiletools.smsmessenger.helpers.refreshMessages +import com.simplemobiletools.smsmessenger.helpers.sendMessage + +class ScheduledMessageReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + val wakelock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "simple.messenger:scheduled.message.receiver") + wakelock.acquire(3000) + + + ensureBackgroundThread { + handleIntent(context, intent) + } + } + + private fun handleIntent(context: Context, intent: Intent) { + val threadId = intent.getLongExtra(THREAD_ID, 0L) + val messageId = intent.getLongExtra(SCHEDULED_MESSAGE_ID, 0L) + val message = try { + context.messagesDB.getScheduledMessageWithId(threadId, messageId) + } catch (e: Exception) { + e.printStackTrace() + return + } + + val addresses = message.participants.getAddresses() + val attachments = message.attachment?.attachments?.mapNotNull { it.getUri() } ?: emptyList() + + try { + context.sendMessage(message.body, addresses, message.subscriptionId, attachments) + + // delete temporary conversation and message as it's already persisted to the telephony db now + context.deleteScheduledMessage(messageId) + context.conversationsDB.deleteThreadId(messageId) + refreshMessages() + } catch (e: Exception) { + context.showErrorToast(e) + } catch (e: Error) { + context.showErrorToast(e.localizedMessage ?: context.getString(R.string.unknown_error_occurred)) + } + } +} diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/services/HeadlessSmsSendService.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/services/HeadlessSmsSendService.kt index 1ebf9d7a..bdea1dbe 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/services/HeadlessSmsSendService.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/services/HeadlessSmsSendService.kt @@ -4,8 +4,7 @@ import android.app.Service import android.content.Intent import android.net.Uri import com.klinker.android.send_message.Transaction -import com.simplemobiletools.smsmessenger.extensions.getSendMessageSettings -import com.simplemobiletools.smsmessenger.extensions.getThreadId +import com.simplemobiletools.smsmessenger.helpers.getSendMessageSettings import com.simplemobiletools.smsmessenger.receivers.SmsStatusDeliveredReceiver import com.simplemobiletools.smsmessenger.receivers.SmsStatusSentReceiver diff --git a/app/src/main/res/drawable/ic_calendar_month_vector.xml b/app/src/main/res/drawable/ic_calendar_month_vector.xml new file mode 100644 index 00000000..cf05bd36 --- /dev/null +++ b/app/src/main/res/drawable/ic_calendar_month_vector.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_schedule_send_vector.xml b/app/src/main/res/drawable/ic_schedule_send_vector.xml new file mode 100644 index 00000000..4d46188f --- /dev/null +++ b/app/src/main/res/drawable/ic_schedule_send_vector.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/layout/activity_thread.xml b/app/src/main/res/layout/activity_thread.xml index 7854987f..4e3baf2c 100644 --- a/app/src/main/res/layout/activity_thread.xml +++ b/app/src/main/res/layout/activity_thread.xml @@ -117,9 +117,12 @@ android:layout_height="match_parent" android:clipToPadding="false" android:overScrollMode="ifContentScrolls" + android:paddingBottom="@dimen/small_margin" android:scrollbars="none" app:layoutManager="com.simplemobiletools.commons.views.MyLinearLayoutManager" - app:stackFromEnd="true" /> + app:stackFromEnd="true" + tools:itemCount="3" + tools:listitem="@layout/item_sent_message" /> @@ -127,7 +130,7 @@ android:id="@+id/message_divider" android:layout_width="match_parent" android:layout_height="1px" - android:layout_above="@+id/thread_attachments_holder" + android:layout_above="@+id/scheduled_message_holder" android:background="@color/divider_grey" android:importantForAccessibility="no" /> @@ -145,13 +148,57 @@ android:padding="@dimen/normal_margin" android:src="@drawable/ic_plus_vector" /> + + + + + + + + + + diff --git a/app/src/main/res/layout/schedule_message_dialog.xml b/app/src/main/res/layout/schedule_message_dialog.xml new file mode 100644 index 00000000..67d7cf14 --- /dev/null +++ b/app/src/main/res/layout/schedule_message_dialog.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 68e9a072..981b6b02 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -7,4 +7,7 @@ 24dp 15dp 64dp + 20dp + 22sp + 20dp