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