Simple-SMS-Messenger/app/src/main/kotlin/com/simplemobiletools/smsmessenger/activities/ThreadActivity.kt

1877 lines
73 KiB
Kotlin

package com.simplemobiletools.smsmessenger.activities
import android.annotation.SuppressLint
import android.app.Activity
import android.app.AlarmManager
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.res.ColorStateList
import android.graphics.BitmapFactory
import android.graphics.drawable.LayerDrawable
import android.media.MediaMetadataRetriever
import android.net.Uri
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.KeyEvent
import android.view.View
import android.view.WindowManager
import android.view.animation.OvershootInterpolator
import android.view.inputmethod.EditorInfo
import android.widget.LinearLayout
import android.widget.LinearLayout.LayoutParams
import android.widget.RelativeLayout
import android.widget.Toast
import androidx.annotation.StringRes
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.res.ResourcesCompat
import androidx.core.view.*
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.simplemobiletools.commons.dialogs.ConfirmationDialog
import com.simplemobiletools.commons.dialogs.FeatureLockedDialog
import com.simplemobiletools.commons.dialogs.PermissionRequiredDialog
import com.simplemobiletools.commons.dialogs.RadioGroupDialog
import com.simplemobiletools.commons.extensions.*
import com.simplemobiletools.commons.helpers.*
import com.simplemobiletools.commons.models.PhoneNumber
import com.simplemobiletools.commons.models.RadioItem
import com.simplemobiletools.commons.models.SimpleContact
import com.simplemobiletools.commons.views.MyRecyclerView
import com.simplemobiletools.smsmessenger.BuildConfig
import com.simplemobiletools.smsmessenger.R
import com.simplemobiletools.smsmessenger.adapters.AttachmentsAdapter
import com.simplemobiletools.smsmessenger.adapters.AutoCompleteTextViewAdapter
import com.simplemobiletools.smsmessenger.adapters.ThreadAdapter
import com.simplemobiletools.smsmessenger.databinding.ActivityThreadBinding
import com.simplemobiletools.smsmessenger.databinding.ItemSelectedContactBinding
import com.simplemobiletools.smsmessenger.dialogs.InvalidNumberDialog
import com.simplemobiletools.smsmessenger.dialogs.RenameConversationDialog
import com.simplemobiletools.smsmessenger.dialogs.ScheduleMessageDialog
import com.simplemobiletools.smsmessenger.extensions.*
import com.simplemobiletools.smsmessenger.helpers.*
import com.simplemobiletools.smsmessenger.messaging.*
import com.simplemobiletools.smsmessenger.models.*
import com.simplemobiletools.smsmessenger.models.ThreadItem.*
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
class ThreadActivity : SimpleActivity() {
private val MIN_DATE_TIME_DIFF_SECS = 300
private val TYPE_EDIT = 14
private val TYPE_SEND = 15
private val TYPE_DELETE = 16
private val SCROLL_TO_BOTTOM_FAB_LIMIT = 20
private var threadId = 0L
private var currentSIMCardIndex = 0
private var isActivityVisible = false
private var refreshedSinceSent = false
private var threadItems = ArrayList<ThreadItem>()
private var bus: EventBus? = null
private var conversation: Conversation? = null
private var participants = ArrayList<SimpleContact>()
private var privateContacts = ArrayList<SimpleContact>()
private var messages = ArrayList<Message>()
private val availableSIMCards = ArrayList<SIMCard>()
private var lastAttachmentUri: String? = null
private var capturedImageUri: Uri? = null
private var loadingOlderMessages = false
private var allMessagesFetched = false
private var oldestMessageDate = -1
private var wasProtectionHandled = false
private var isRecycleBin = false
private var isScheduledMessage: Boolean = false
private var messageToResend: Long? = null
private var scheduledMessage: Message? = null
private lateinit var scheduledDateTime: DateTime
private var isAttachmentPickerVisible = false
private val binding by viewBinding(ActivityThreadBinding::inflate)
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
finish()
startActivity(intent)
}
override fun onCreate(savedInstanceState: Bundle?) {
isMaterialActivity = true
super.onCreate(savedInstanceState)
setContentView(binding.root)
setupOptionsMenu()
refreshMenuItems()
updateMaterialActivityViews(binding.threadCoordinator, null, useTransparentNavigation = false, useTopSearchMenu = false)
setupMaterialScrollListener(null, binding.threadToolbar)
val extras = intent.extras
if (extras == null) {
toast(com.simplemobiletools.commons.R.string.unknown_error_occurred)
finish()
return
}
threadId = intent.getLongExtra(THREAD_ID, 0L)
intent.getStringExtra(THREAD_TITLE)?.let {
binding.threadToolbar.title = it
}
isRecycleBin = intent.getBooleanExtra(IS_RECYCLE_BIN, false)
wasProtectionHandled = intent.getBooleanExtra(WAS_PROTECTION_HANDLED, false)
bus = EventBus.getDefault()
bus!!.register(this)
if (savedInstanceState == null) {
if (!wasProtectionHandled) {
handleAppPasswordProtection {
wasProtectionHandled = it
if (it) {
clearAllMessagesIfNeeded {
loadConversation()
}
} else {
finish()
}
}
} else {
loadConversation()
}
}
setupAttachmentPickerView()
setupKeyboardListener()
hideAttachmentPicker()
maybeSetupRecycleBinView()
}
override fun onResume() {
super.onResume()
setupToolbar(binding.threadToolbar, NavigationIcon.Arrow, statusBarColor = getProperBackgroundColor())
val smsDraft = getSmsDraft(threadId)
if (smsDraft != null) {
binding.messageHolder.threadTypeMessage.setText(smsDraft)
}
isActivityVisible = true
notificationManager.cancel(threadId.hashCode())
ensureBackgroundThread {
val newConv = conversationsDB.getConversationWithThreadId(threadId)
if (newConv != null) {
conversation = newConv
runOnUiThread {
setupThreadTitle()
}
}
}
val bottomBarColor = getBottomBarColor()
binding.messageHolder.root.setBackgroundColor(bottomBarColor)
binding.shortCodeHolder.root.setBackgroundColor(bottomBarColor)
updateNavigationBarColor(bottomBarColor)
}
override fun onPause() {
super.onPause()
if (binding.messageHolder.threadTypeMessage.value != "" && getAttachmentSelections().isEmpty()) {
saveSmsDraft(binding.messageHolder.threadTypeMessage.value, threadId)
} else {
deleteSmsDraft(threadId)
}
bus?.post(Events.RefreshMessages())
isActivityVisible = false
}
override fun onBackPressed() {
isAttachmentPickerVisible = false
if (binding.messageHolder.attachmentPickerHolder.isVisible()) {
hideAttachmentPicker()
} else {
super.onBackPressed()
}
}
override fun onDestroy() {
super.onDestroy()
bus?.unregister(this)
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putBoolean(WAS_PROTECTION_HANDLED, wasProtectionHandled)
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
wasProtectionHandled = savedInstanceState.getBoolean(WAS_PROTECTION_HANDLED, false)
if (!wasProtectionHandled) {
handleAppPasswordProtection {
wasProtectionHandled = it
if (it) {
loadConversation()
} else {
finish()
}
}
} else {
loadConversation()
}
}
private fun refreshMenuItems() {
val firstPhoneNumber = participants.firstOrNull()?.phoneNumbers?.firstOrNull()?.value
val useArchive = config.useThreadsArchive
binding.threadToolbar.menu.apply {
findItem(R.id.delete).isVisible = threadItems.isNotEmpty()
findItem(R.id.restore).isVisible = threadItems.isNotEmpty() && isRecycleBin
findItem(R.id.archive).isVisible = threadItems.isNotEmpty() && conversation?.isArchived == false && !isRecycleBin && useArchive
findItem(R.id.unarchive).isVisible = threadItems.isNotEmpty() && conversation?.isArchived == true && !isRecycleBin && useArchive
findItem(R.id.rename_conversation).isVisible = participants.size > 1 && conversation != null && !isRecycleBin
findItem(R.id.conversation_details).isVisible = conversation != null && !isRecycleBin
findItem(R.id.block_number).title = addLockedLabelIfNeeded(com.simplemobiletools.commons.R.string.block_number)
findItem(R.id.block_number).isVisible = isNougatPlus() && !isRecycleBin
findItem(R.id.dial_number).isVisible = participants.size == 1 && !isSpecialNumber() && !isRecycleBin
findItem(R.id.manage_people).isVisible = !isSpecialNumber() && !isRecycleBin
findItem(R.id.mark_as_unread).isVisible = threadItems.isNotEmpty() && !isRecycleBin
// allow saving number in cases when we dont have it stored yet and it is a casual readable number
findItem(R.id.add_number_to_contact).isVisible = participants.size == 1 && participants.first().name == firstPhoneNumber && firstPhoneNumber.any {
it.isDigit()
} && !isRecycleBin
}
}
private fun setupOptionsMenu() {
binding.threadToolbar.setOnMenuItemClickListener { menuItem ->
if (participants.isEmpty()) {
return@setOnMenuItemClickListener true
}
when (menuItem.itemId) {
R.id.block_number -> tryBlocking()
R.id.delete -> askConfirmDelete()
R.id.restore -> askConfirmRestoreAll()
R.id.archive -> archiveConversation()
R.id.unarchive -> unarchiveConversation()
R.id.rename_conversation -> renameConversation()
R.id.conversation_details -> showConversationDetails()
R.id.add_number_to_contact -> addNumberToContact()
R.id.dial_number -> dialNumber()
R.id.manage_people -> managePeople()
R.id.mark_as_unread -> markAsUnread()
else -> return@setOnMenuItemClickListener false
}
return@setOnMenuItemClickListener true
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) {
super.onActivityResult(requestCode, resultCode, resultData)
if (resultCode != Activity.RESULT_OK) return
val data = resultData?.data
messageToResend = null
if (requestCode == CAPTURE_PHOTO_INTENT && capturedImageUri != null) {
addAttachment(capturedImageUri!!)
} else if (data != null) {
when (requestCode) {
CAPTURE_VIDEO_INTENT, PICK_DOCUMENT_INTENT, CAPTURE_AUDIO_INTENT, PICK_PHOTO_INTENT, PICK_VIDEO_INTENT -> addAttachment(data)
PICK_CONTACT_INTENT -> addContactAttachment(data)
PICK_SAVE_FILE_INTENT -> saveAttachment(resultData)
}
}
}
private fun setupCachedMessages(callback: () -> Unit) {
ensureBackgroundThread {
messages = try {
if (isRecycleBin) {
messagesDB.getThreadMessagesFromRecycleBin(threadId)
} else {
if (config.useRecycleBin) {
messagesDB.getNonRecycledThreadMessages(threadId)
} else {
messagesDB.getThreadMessages(threadId)
}
}.toMutableList() as ArrayList<Message>
} catch (e: Exception) {
ArrayList()
}
clearExpiredScheduledMessages(threadId, messages)
messages.removeAll { it.isScheduled && it.millis() < System.currentTimeMillis() }
messages.sortBy { it.date }
if (messages.size > MESSAGES_LIMIT) {
messages = ArrayList(messages.takeLast(MESSAGES_LIMIT))
}
setupParticipants()
setupAdapter()
runOnUiThread {
if (messages.isEmpty()) {
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
binding.messageHolder.threadTypeMessage.requestFocus()
}
setupThreadTitle()
setupSIMSelector()
updateMessageType()
callback()
}
}
}
private fun setupThread() {
val privateCursor = getMyContactsCursor(favoritesOnly = false, withPhoneNumbersOnly = true)
ensureBackgroundThread {
privateContacts = MyContactsContentProvider.getSimpleContacts(this, privateCursor)
val cachedMessagesCode = messages.clone().hashCode()
if (!isRecycleBin) {
messages = getMessages(threadId, true)
if (config.useRecycleBin) {
val recycledMessages = messagesDB.getThreadMessagesFromRecycleBin(threadId).map { it.id }
messages = messages.filter { !recycledMessages.contains(it.id) }.toMutableList() as ArrayList<Message>
}
}
val hasParticipantWithoutName = participants.any { contact ->
contact.phoneNumbers.map { it.normalizedNumber }.contains(contact.name)
}
try {
if (participants.isNotEmpty() && messages.hashCode() == cachedMessagesCode && !hasParticipantWithoutName) {
setupAdapter()
return@ensureBackgroundThread
}
} catch (ignored: Exception) {
}
setupParticipants()
// check if no participant came from a privately stored contact in Simple Contacts
if (privateContacts.isNotEmpty()) {
val senderNumbersToReplace = HashMap<String, String>()
participants.filter { it.doesHavePhoneNumber(it.name) }.forEach { participant ->
privateContacts.firstOrNull { it.doesHavePhoneNumber(participant.phoneNumbers.first().normalizedNumber) }?.apply {
senderNumbersToReplace[participant.phoneNumbers.first().normalizedNumber] = name
participant.name = name
participant.photoUri = photoUri
}
}
messages.forEach { message ->
if (senderNumbersToReplace.keys.contains(message.senderName)) {
message.senderName = senderNumbersToReplace[message.senderName]!!
}
}
}
if (participants.isEmpty()) {
val name = intent.getStringExtra(THREAD_TITLE) ?: ""
val number = intent.getStringExtra(THREAD_NUMBER)
if (number == null) {
toast(com.simplemobiletools.commons.R.string.unknown_error_occurred)
finish()
return@ensureBackgroundThread
}
val phoneNumber = PhoneNumber(number, 0, "", number)
val contact = SimpleContact(0, 0, name, "", arrayListOf(phoneNumber), ArrayList(), ArrayList())
participants.add(contact)
}
if (!isRecycleBin) {
messages.chunked(30).forEach { currentMessages ->
messagesDB.insertMessages(*currentMessages.toTypedArray())
}
}
setupAttachmentSizes()
setupAdapter()
runOnUiThread {
setupThreadTitle()
setupSIMSelector()
}
}
}
private fun getOrCreateThreadAdapter(): ThreadAdapter {
var currAdapter = binding.threadMessagesList.adapter
if (currAdapter == null) {
currAdapter = ThreadAdapter(
activity = this,
recyclerView = binding.threadMessagesList,
itemClick = { handleItemClick(it) },
isRecycleBin = isRecycleBin,
deleteMessages = { messages, toRecycleBin, fromRecycleBin -> deleteMessages(messages, toRecycleBin, fromRecycleBin) }
)
binding.threadMessagesList.adapter = currAdapter
binding.threadMessagesList.endlessScrollListener = object : MyRecyclerView.EndlessScrollListener {
override fun updateBottom() {}
override fun updateTop() {
fetchNextMessages()
}
}
}
return currAdapter as ThreadAdapter
}
private fun setupAdapter() {
threadItems = getThreadItems()
runOnUiThread {
refreshMenuItems()
getOrCreateThreadAdapter().apply {
val layoutManager = binding.threadMessagesList.layoutManager as LinearLayoutManager
val lastPosition = itemCount - 1
val lastVisiblePosition = layoutManager.findLastVisibleItemPosition()
val shouldScrollToBottom = currentList.lastOrNull() != threadItems.lastOrNull() && lastPosition - lastVisiblePosition == 1
updateMessages(threadItems, if (shouldScrollToBottom) lastPosition else -1)
}
}
SimpleContactsHelper(this).getAvailableContacts(false) { contacts ->
contacts.addAll(privateContacts)
runOnUiThread {
val adapter = AutoCompleteTextViewAdapter(this, contacts)
binding.addContactOrNumber.setAdapter(adapter)
binding.addContactOrNumber.imeOptions = EditorInfo.IME_ACTION_NEXT
binding.addContactOrNumber.setOnItemClickListener { _, _, position, _ ->
val currContacts = (binding.addContactOrNumber.adapter as AutoCompleteTextViewAdapter).resultList
val selectedContact = currContacts[position]
addSelectedContact(selectedContact)
}
binding.addContactOrNumber.onTextChangeListener {
binding.confirmInsertedNumber.beVisibleIf(it.length > 2)
}
}
}
runOnUiThread {
binding.confirmInsertedNumber.setOnClickListener {
val number = binding.addContactOrNumber.value
val phoneNumber = PhoneNumber(number, 0, "", number)
val contact = SimpleContact(number.hashCode(), number.hashCode(), number, "", arrayListOf(phoneNumber), ArrayList(), ArrayList())
addSelectedContact(contact)
}
}
}
private fun scrollToBottom() {
val position = getOrCreateThreadAdapter().currentList.lastIndex
if (position >= 0) {
binding.threadMessagesList.smoothScrollToPosition(position)
}
}
private fun setupScrollFab() {
binding.threadMessagesList.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val layoutManager = binding.threadMessagesList.layoutManager as LinearLayoutManager
val lastVisibleItemPosition = layoutManager.findLastCompletelyVisibleItemPosition()
val isCloseToBottom = lastVisibleItemPosition >= getOrCreateThreadAdapter().itemCount - SCROLL_TO_BOTTOM_FAB_LIMIT
if (isCloseToBottom) {
binding.scrollToBottomFab.hide()
} else {
binding.scrollToBottomFab.show()
}
}
})
}
private fun handleItemClick(any: Any) {
when {
any is Message && any.isScheduled -> showScheduledMessageInfo(any)
any is ThreadError -> {
binding.messageHolder.threadTypeMessage.setText(any.messageText)
messageToResend = any.messageId
}
}
}
private fun deleteMessages(messagesToRemove: List<Message>, toRecycleBin: Boolean, fromRecycleBin: Boolean) {
val deletePosition = threadItems.indexOf(messagesToRemove.first())
messages.removeAll(messagesToRemove.toSet())
threadItems = getThreadItems()
runOnUiThread {
if (messages.isEmpty()) {
finish()
} else {
getOrCreateThreadAdapter().apply {
updateMessages(threadItems, scrollPosition = deletePosition)
finishActMode()
}
}
}
messagesToRemove.forEach { message ->
val messageId = message.id
if (message.isScheduled) {
deleteScheduledMessage(messageId)
cancelScheduleSendPendingIntent(messageId)
} else {
if (toRecycleBin) {
moveMessageToRecycleBin(messageId)
} else if (fromRecycleBin) {
restoreMessageFromRecycleBin(messageId)
} else {
deleteMessage(messageId, message.isMMS)
}
}
}
updateLastConversationMessage(threadId)
// move all scheduled messages to a temporary thread when there are no real messages left
if (messages.isNotEmpty() && messages.all { it.isScheduled }) {
val scheduledMessage = messages.last()
val fakeThreadId = generateRandomId()
createTemporaryThread(scheduledMessage, fakeThreadId, conversation)
updateScheduledMessagesThreadId(messages, fakeThreadId)
threadId = fakeThreadId
}
}
private fun fetchNextMessages() {
if (messages.isEmpty() || allMessagesFetched || loadingOlderMessages) {
if (allMessagesFetched) {
getOrCreateThreadAdapter().apply {
val newList = currentList.toMutableList().apply {
removeAll { it is ThreadLoading }
}
updateMessages(newMessages = newList as ArrayList<ThreadItem>, scrollPosition = 0)
}
}
return
}
val firstItem = messages.first()
val dateOfFirstItem = firstItem.date
if (oldestMessageDate == dateOfFirstItem) {
allMessagesFetched = true
return
}
oldestMessageDate = dateOfFirstItem
loadingOlderMessages = true
ensureBackgroundThread {
val olderMessages = getMessages(threadId, true, oldestMessageDate)
.filter { message -> !messages.contains(message) }
messages.addAll(0, olderMessages)
allMessagesFetched = olderMessages.isEmpty()
threadItems = getThreadItems()
runOnUiThread {
loadingOlderMessages = false
val itemAtRefreshIndex = threadItems.indexOfFirst { it == firstItem }
getOrCreateThreadAdapter().updateMessages(threadItems, itemAtRefreshIndex)
}
}
}
private fun loadConversation() {
handlePermission(PERMISSION_READ_PHONE_STATE) { granted ->
if (granted) {
setupButtons()
setupConversation()
setupCachedMessages {
val searchedMessageId = intent.getLongExtra(SEARCHED_MESSAGE_ID, -1L)
intent.removeExtra(SEARCHED_MESSAGE_ID)
if (searchedMessageId != -1L) {
val index = threadItems.indexOfFirst { (it as? Message)?.id == searchedMessageId }
if (index != -1) {
binding.threadMessagesList.smoothScrollToPosition(index)
}
}
setupThread()
setupScrollFab()
}
} else {
finish()
}
}
}
private fun setupConversation() {
ensureBackgroundThread {
conversation = conversationsDB.getConversationWithThreadId(threadId)
}
}
private fun setupButtons() = binding.apply {
updateTextColors(threadHolder)
val textColor = getProperTextColor()
binding.messageHolder.apply {
threadSendMessage.setTextColor(textColor)
threadSendMessage.compoundDrawables.forEach {
it?.applyColorFilter(textColor)
}
confirmManageContacts.applyColorFilter(textColor)
threadAddAttachment.applyColorFilter(textColor)
val properPrimaryColor = getProperPrimaryColor()
threadMessagesFastscroller.updateColors(properPrimaryColor)
threadCharacterCounter.beVisibleIf(config.showCharacterCounter)
threadCharacterCounter.setTextSize(TypedValue.COMPLEX_UNIT_PX, getTextSize())
threadTypeMessage.setTextSize(TypedValue.COMPLEX_UNIT_PX, getTextSize())
threadSendMessage.setOnClickListener {
sendMessage()
}
threadSendMessage.setOnLongClickListener {
if (!isScheduledMessage) {
launchScheduleSendDialog()
}
true
}
threadSendMessage.isClickable = false
threadTypeMessage.onTextChangeListener {
messageToResend = null
checkSendMessageAvailability()
val messageString = if (config.useSimpleCharacters) {
it.normalizeString()
} else {
it
}
val messageLength = SmsMessage.calculateLength(messageString, false)
threadCharacterCounter.text = "${messageLength[2]}/${messageLength[0]}"
}
if (config.sendOnEnter) {
threadTypeMessage.inputType = EditorInfo.TYPE_TEXT_FLAG_CAP_SENTENCES
threadTypeMessage.imeOptions = EditorInfo.IME_ACTION_SEND
threadTypeMessage.setOnEditorActionListener { _, action, _ ->
if (action == EditorInfo.IME_ACTION_SEND) {
dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER))
return@setOnEditorActionListener true
}
false
}
threadTypeMessage.setOnKeyListener { _, keyCode, event ->
if (keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_UP) {
sendMessage()
return@setOnKeyListener true
}
false
}
}
confirmManageContacts.setOnClickListener {
hideKeyboard()
threadAddContacts.beGone()
val numbers = HashSet<String>()
participants.forEach { contact ->
contact.phoneNumbers.forEach {
numbers.add(it.normalizedNumber)
}
}
val newThreadId = getThreadId(numbers)
if (threadId != newThreadId) {
hideKeyboard()
Intent(this@ThreadActivity, ThreadActivity::class.java).apply {
putExtra(THREAD_ID, newThreadId)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
startActivity(this)
}
}
}
threadTypeMessage.setText(intent.getStringExtra(THREAD_TEXT))
threadAddAttachment.setOnClickListener {
if (attachmentPickerHolder.isVisible()) {
isAttachmentPickerVisible = false
WindowCompat.getInsetsController(window, threadTypeMessage).show(WindowInsetsCompat.Type.ime())
} else {
isAttachmentPickerVisible = true
showOrHideAttachmentPicker()
WindowCompat.getInsetsController(window, threadTypeMessage).hide(WindowInsetsCompat.Type.ime())
}
window.decorView.requestApplyInsets()
}
if (intent.extras?.containsKey(THREAD_ATTACHMENT_URI) == true) {
val uri = Uri.parse(intent.getStringExtra(THREAD_ATTACHMENT_URI))
addAttachment(uri)
} else if (intent.extras?.containsKey(THREAD_ATTACHMENT_URIS) == true) {
(intent.getSerializableExtra(THREAD_ATTACHMENT_URIS) as? ArrayList<Uri>)?.forEach {
addAttachment(it)
}
}
scrollToBottomFab.setOnClickListener {
scrollToBottom()
}
scrollToBottomFab.backgroundTintList = ColorStateList.valueOf(getBottomBarColor())
scrollToBottomFab.applyColorFilter(textColor)
}
setupScheduleSendUi()
}
private fun askForExactAlarmPermissionIfNeeded(callback: () -> Unit = {}) {
if (isSPlus()) {
val alarmManager: AlarmManager = getSystemService(ALARM_SERVICE) as AlarmManager
if (alarmManager.canScheduleExactAlarms()) {
callback()
} else {
PermissionRequiredDialog(
activity = this,
textId = com.simplemobiletools.commons.R.string.allow_alarm_scheduled_messages,
positiveActionCallback = {
openRequestExactAlarmSettings(BuildConfig.APPLICATION_ID)
},
)
}
} else {
callback()
}
}
private fun setupAttachmentSizes() {
messages.filter { it.attachment != null }.forEach { message ->
message.attachment!!.attachments.forEach {
try {
if (it.mimetype.startsWith("image/")) {
val fileOptions = BitmapFactory.Options()
fileOptions.inJustDecodeBounds = true
BitmapFactory.decodeStream(contentResolver.openInputStream(it.getUri()), null, fileOptions)
it.width = fileOptions.outWidth
it.height = fileOptions.outHeight
} else if (it.mimetype.startsWith("video/")) {
val metaRetriever = MediaMetadataRetriever()
metaRetriever.setDataSource(this, it.getUri())
it.width = metaRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)!!.toInt()
it.height = metaRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)!!.toInt()
}
if (it.width < 0) {
it.width = 0
}
if (it.height < 0) {
it.height = 0
}
} catch (ignored: Exception) {
}
}
}
}
private fun setupParticipants() {
if (participants.isEmpty()) {
participants = if (messages.isEmpty()) {
val intentNumbers = getPhoneNumbersFromIntent()
val participants = getThreadParticipants(threadId, null)
fixParticipantNumbers(participants, intentNumbers)
} else {
messages.first().participants
}
runOnUiThread {
maybeDisableShortCodeReply()
}
}
}
private fun isSpecialNumber(): Boolean {
val addresses = participants.getAddresses()
return addresses.any { isShortCodeWithLetters(it) }
}
private fun maybeDisableShortCodeReply() {
if (isSpecialNumber() && !isRecycleBin) {
binding.messageHolder.root.beGone()
binding.shortCodeHolder.root.beVisible()
val textColor = getProperTextColor()
binding.shortCodeHolder.replyDisabledText.setTextColor(textColor)
binding.shortCodeHolder.replyDisabledInfo.apply {
applyColorFilter(textColor)
setOnClickListener {
InvalidNumberDialog(
activity = this@ThreadActivity,
text = getString(R.string.invalid_short_code_desc)
)
}
if (isOreoPlus()) {
tooltipText = getString(com.simplemobiletools.commons.R.string.more_info)
}
}
}
}
private fun setupThreadTitle() {
val title = conversation?.title
binding.threadToolbar.title = if (!title.isNullOrEmpty()) {
title
} else {
participants.getThreadTitle()
}
}
@SuppressLint("MissingPermission")
private fun setupSIMSelector() {
val availableSIMs = subscriptionManagerCompat().activeSubscriptionInfoList ?: return
if (availableSIMs.size > 1) {
availableSIMs.forEachIndexed { index, subscriptionInfo ->
var label = subscriptionInfo.displayName?.toString() ?: ""
if (subscriptionInfo.number?.isNotEmpty() == true) {
label += " (${subscriptionInfo.number})"
}
val simCard = SIMCard(index + 1, subscriptionInfo.subscriptionId, label)
availableSIMCards.add(simCard)
}
val numbers = ArrayList<String>()
participants.forEach { contact ->
contact.phoneNumbers.forEach {
numbers.add(it.normalizedNumber)
}
}
if (numbers.isEmpty()) {
return
}
currentSIMCardIndex = getProperSimIndex(availableSIMs, numbers)
binding.messageHolder.threadSelectSimIcon.applyColorFilter(getProperTextColor())
binding.messageHolder.threadSelectSimIcon.beVisible()
binding.messageHolder.threadSelectSimNumber.beVisible()
if (availableSIMCards.isNotEmpty()) {
binding.messageHolder.threadSelectSimIcon.setOnClickListener {
currentSIMCardIndex = (currentSIMCardIndex + 1) % availableSIMCards.size
val currentSIMCard = availableSIMCards[currentSIMCardIndex]
binding.messageHolder.threadSelectSimNumber.text = currentSIMCard.id.toString()
val currentSubscriptionId = currentSIMCard.subscriptionId
numbers.forEach {
config.saveUseSIMIdAtNumber(it, currentSubscriptionId)
}
toast(currentSIMCard.label)
}
}
binding.messageHolder.threadSelectSimNumber.setTextColor(getProperTextColor().getContrastColor())
try {
binding.messageHolder.threadSelectSimNumber.text = (availableSIMCards[currentSIMCardIndex].id).toString()
} catch (e: Exception) {
showErrorToast(e)
}
}
}
@SuppressLint("MissingPermission")
private fun getProperSimIndex(availableSIMs: MutableList<SubscriptionInfo>, numbers: List<String>): Int {
val userPreferredSimId = config.getUseSIMIdAtNumber(numbers.first())
val userPreferredSimIdx = availableSIMs.indexOfFirstOrNull { it.subscriptionId == userPreferredSimId }
val lastMessage = messages.lastOrNull()
val senderPreferredSimIdx = if (lastMessage?.isReceivedMessage() == true) {
availableSIMs.indexOfFirstOrNull { it.subscriptionId == lastMessage.subscriptionId }
} else {
null
}
val defaultSmsSubscriptionId = SmsManager.getDefaultSmsSubscriptionId()
val systemPreferredSimIdx = if (defaultSmsSubscriptionId >= 0) {
availableSIMs.indexOfFirstOrNull { it.subscriptionId == defaultSmsSubscriptionId }
} else {
null
}
return userPreferredSimIdx ?: senderPreferredSimIdx ?: systemPreferredSimIdx ?: 0
}
private fun tryBlocking() {
if (isOrWasThankYouInstalled()) {
blockNumber()
} else {
FeatureLockedDialog(this) { }
}
}
private fun blockNumber() {
val numbers = participants.getAddresses()
val numbersString = TextUtils.join(", ", numbers)
val question = String.format(resources.getString(com.simplemobiletools.commons.R.string.block_confirmation), numbersString)
ConfirmationDialog(this, question) {
ensureBackgroundThread {
numbers.forEach {
addBlockedNumber(it)
}
refreshMessages()
finish()
}
}
}
private fun askConfirmDelete() {
val confirmationMessage = R.string.delete_whole_conversation_confirmation
ConfirmationDialog(this, getString(confirmationMessage)) {
ensureBackgroundThread {
if (isRecycleBin) {
emptyMessagesRecycleBinForConversation(threadId)
} else {
deleteConversation(threadId)
}
runOnUiThread {
refreshMessages()
finish()
}
}
}
}
private fun askConfirmRestoreAll() {
ConfirmationDialog(this, getString(R.string.restore_confirmation)) {
ensureBackgroundThread {
restoreAllMessagesFromRecycleBinForConversation(threadId)
runOnUiThread {
refreshMessages()
finish()
}
}
}
}
private fun archiveConversation() {
ensureBackgroundThread {
updateConversationArchivedStatus(threadId, true)
runOnUiThread {
refreshMessages()
finish()
}
}
}
private fun unarchiveConversation() {
ensureBackgroundThread {
updateConversationArchivedStatus(threadId, false)
runOnUiThread {
refreshMessages()
finish()
}
}
}
private fun dialNumber() {
val phoneNumber = participants.first().phoneNumbers.first().normalizedNumber
dialNumber(phoneNumber)
}
private fun managePeople() {
if (binding.threadAddContacts.isVisible()) {
hideKeyboard()
binding.threadAddContacts.beGone()
} else {
showSelectedContacts()
binding.threadAddContacts.beVisible()
binding.addContactOrNumber.requestFocus()
showKeyboard(binding.addContactOrNumber)
}
}
private fun showSelectedContacts() {
val properPrimaryColor = getProperPrimaryColor()
val views = ArrayList<View>()
participants.forEach { contact ->
ItemSelectedContactBinding.inflate(layoutInflater).apply {
val selectedContactBg = resources.getDrawable(R.drawable.item_selected_contact_background)
(selectedContactBg as LayerDrawable).findDrawableByLayerId(R.id.selected_contact_bg).applyColorFilter(properPrimaryColor)
selectedContactHolder.background = selectedContactBg
selectedContactName.text = contact.name
selectedContactName.setTextColor(properPrimaryColor.getContrastColor())
selectedContactRemove.applyColorFilter(properPrimaryColor.getContrastColor())
selectedContactRemove.setOnClickListener {
if (contact.rawId != participants.first().rawId) {
removeSelectedContact(contact.rawId)
}
}
views.add(root)
}
}
showSelectedContact(views)
}
private fun addSelectedContact(contact: SimpleContact) {
binding.addContactOrNumber.setText("")
if (participants.map { it.rawId }.contains(contact.rawId)) {
return
}
participants.add(contact)
showSelectedContacts()
updateMessageType()
}
private fun markAsUnread() {
ensureBackgroundThread {
conversationsDB.markUnread(threadId)
markThreadMessagesUnread(threadId)
runOnUiThread {
finish()
bus?.post(Events.RefreshMessages())
}
}
}
private fun addNumberToContact() {
val phoneNumber = participants.firstOrNull()?.phoneNumbers?.firstOrNull()?.normalizedNumber ?: return
Intent().apply {
action = Intent.ACTION_INSERT_OR_EDIT
type = "vnd.android.cursor.item/contact"
putExtra(KEY_PHONE, phoneNumber)
launchActivityIntent(this)
}
}
private fun renameConversation() {
RenameConversationDialog(this, conversation!!) { title ->
ensureBackgroundThread {
conversation = renameConversation(conversation!!, newTitle = title)
runOnUiThread {
setupThreadTitle()
}
}
}
}
private fun showConversationDetails() {
Intent(this, ConversationDetailsActivity::class.java).apply {
putExtra(THREAD_ID, threadId)
startActivity(this)
}
}
@SuppressLint("MissingPermission")
private fun getThreadItems(): ArrayList<ThreadItem> {
val items = ArrayList<ThreadItem>()
if (isFinishing) {
return items
}
messages.sortBy { it.date }
val subscriptionIdToSimId = HashMap<Int, String>()
subscriptionIdToSimId[-1] = "?"
subscriptionManagerCompat().activeSubscriptionInfoList?.forEachIndexed { index, subscriptionInfo ->
subscriptionIdToSimId[subscriptionInfo.subscriptionId] = "${index + 1}"
}
var prevDateTime = 0
var prevSIMId = -2
var hadUnreadItems = false
val cnt = messages.size
for (i in 0 until cnt) {
val message = messages.getOrNull(i) ?: continue
// do not show the date/time above every message, only if the difference between the 2 messages is at least MIN_DATE_TIME_DIFF_SECS,
// or if the message is sent from a different SIM
val isSentFromDifferentKnownSIM = prevSIMId != -1 && message.subscriptionId != -1 && prevSIMId != message.subscriptionId
if (message.date - prevDateTime > MIN_DATE_TIME_DIFF_SECS || isSentFromDifferentKnownSIM) {
val simCardID = subscriptionIdToSimId[message.subscriptionId] ?: "?"
items.add(ThreadDateTime(message.date, simCardID))
prevDateTime = message.date
}
items.add(message)
if (message.type == Telephony.Sms.MESSAGE_TYPE_FAILED) {
items.add(ThreadError(message.id, message.body))
}
if (message.type == Telephony.Sms.MESSAGE_TYPE_OUTBOX) {
items.add(ThreadSending(message.id))
}
if (!message.read) {
hadUnreadItems = true
markMessageRead(message.id, message.isMMS)
conversationsDB.markRead(threadId)
}
if (i == cnt - 1 && (message.type == Telephony.Sms.MESSAGE_TYPE_SENT)) {
items.add(ThreadSent(message.id, delivered = message.status == Telephony.Sms.STATUS_COMPLETE))
}
prevSIMId = message.subscriptionId
}
if (hadUnreadItems) {
bus?.post(Events.RefreshMessages())
}
if (!allMessagesFetched && messages.size >= MESSAGES_LIMIT) {
val threadLoading = ThreadLoading(generateRandomId())
items.add(0, threadLoading)
}
return items
}
private fun launchActivityForResult(intent: Intent, requestCode: Int, @StringRes error: Int = com.simplemobiletools.commons.R.string.no_app_found) {
hideKeyboard()
try {
startActivityForResult(intent, requestCode)
} catch (e: ActivityNotFoundException) {
showErrorToast(getString(error))
} catch (e: Exception) {
showErrorToast(e)
}
}
private fun getAttachmentsDir(): File {
return File(cacheDir, "attachments").apply {
if (!exists()) {
mkdirs()
}
}
}
private fun launchCapturePhotoIntent() {
val imageFile = File.createTempFile("attachment_", ".jpg", getAttachmentsDir())
capturedImageUri = getMyFileUri(imageFile)
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply {
putExtra(MediaStore.EXTRA_OUTPUT, capturedImageUri)
}
launchActivityForResult(intent, CAPTURE_PHOTO_INTENT)
}
private fun launchCaptureVideoIntent() {
val intent = Intent(MediaStore.ACTION_VIDEO_CAPTURE)
launchActivityForResult(intent, CAPTURE_VIDEO_INTENT)
}
private fun launchCaptureAudioIntent() {
val intent = Intent(MediaStore.Audio.Media.RECORD_SOUND_ACTION)
launchActivityForResult(intent, CAPTURE_AUDIO_INTENT)
}
private fun launchGetContentIntent(mimeTypes: Array<String>, requestCode: Int) {
Intent(Intent.ACTION_GET_CONTENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "*/*"
putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes)
launchActivityForResult(this, requestCode)
}
}
private fun launchPickContactIntent() {
Intent(Intent.ACTION_PICK).apply {
type = ContactsContract.Contacts.CONTENT_TYPE
launchActivityForResult(this, PICK_CONTACT_INTENT)
}
}
private fun addContactAttachment(contactUri: Uri) {
ensureBackgroundThread {
val contact = ContactsHelper(this).getContactFromUri(contactUri)
if (contact != null) {
val outputFile = File(getAttachmentsDir(), "${contact.contactId}.vcf")
val outputStream = outputFile.outputStream()
VcfExporter().exportContacts(
activity = this,
outputStream = outputStream,
contacts = arrayListOf(contact),
showExportingToast = false,
) {
if (it == ExportResult.EXPORT_OK) {
val vCardUri = getMyFileUri(outputFile)
runOnUiThread {
addAttachment(vCardUri)
}
} else {
toast(com.simplemobiletools.commons.R.string.unknown_error_occurred)
}
}
} else {
toast(com.simplemobiletools.commons.R.string.unknown_error_occurred)
}
}
}
private fun getAttachmentsAdapter(): AttachmentsAdapter? {
val adapter = binding.messageHolder.threadAttachmentsRecyclerview.adapter
return adapter as? AttachmentsAdapter
}
private fun getAttachmentSelections() = getAttachmentsAdapter()?.attachments ?: emptyList()
private fun addAttachment(uri: Uri) {
val id = uri.toString()
if (getAttachmentSelections().any { it.id == id }) {
toast(R.string.duplicate_item_warning)
return
}
val mimeType = contentResolver.getType(uri)
if (mimeType == null) {
toast(com.simplemobiletools.commons.R.string.unknown_error_occurred)
return
}
val isImage = mimeType.isImageMimeType()
val isGif = mimeType.isGifMimeType()
if (isGif || !isImage) {
// is it assumed that images will always be compressed below the max MMS size limit
val fileSize = getFileSizeFromUri(uri)
val mmsFileSizeLimit = config.mmsFileSizeLimit
if (mmsFileSizeLimit != FILE_SIZE_NONE && fileSize > mmsFileSizeLimit) {
toast(R.string.attachment_sized_exceeds_max_limit, length = Toast.LENGTH_LONG)
return
}
}
var adapter = getAttachmentsAdapter()
if (adapter == null) {
adapter = AttachmentsAdapter(
activity = this,
recyclerView = binding.messageHolder.threadAttachmentsRecyclerview,
onAttachmentsRemoved = {
binding.messageHolder.threadAttachmentsRecyclerview.beGone()
checkSendMessageAvailability()
},
onReady = { checkSendMessageAvailability() }
)
binding.messageHolder.threadAttachmentsRecyclerview.adapter = adapter
}
binding.messageHolder.threadAttachmentsRecyclerview.beVisible()
val attachment = AttachmentSelection(
id = id,
uri = uri,
mimetype = mimeType,
filename = getFilenameFromUri(uri),
isPending = isImage && !isGif
)
adapter.addAttachment(attachment)
checkSendMessageAvailability()
}
private fun saveAttachment(resultData: Intent) {
val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
applicationContext.contentResolver.takePersistableUriPermission(resultData.data!!, takeFlags)
var inputStream: InputStream? = null
var outputStream: OutputStream? = null
try {
inputStream = contentResolver.openInputStream(Uri.parse(lastAttachmentUri))
outputStream = contentResolver.openOutputStream(Uri.parse(resultData.dataString!!), "rwt")
inputStream!!.copyTo(outputStream!!)
outputStream.flush()
toast(com.simplemobiletools.commons.R.string.file_saved)
} catch (e: Exception) {
showErrorToast(e)
} finally {
inputStream?.close()
outputStream?.close()
}
lastAttachmentUri = null
}
private fun checkSendMessageAvailability() {
binding.messageHolder.apply {
if (threadTypeMessage.text!!.isNotEmpty() || (getAttachmentSelections().isNotEmpty() && !getAttachmentSelections().any { it.isPending })) {
threadSendMessage.isEnabled = true
threadSendMessage.isClickable = true
threadSendMessage.alpha = 0.9f
} else {
threadSendMessage.isEnabled = false
threadSendMessage.isClickable = false
threadSendMessage.alpha = 0.4f
}
}
updateMessageType()
}
private fun sendMessage() {
var text = binding.messageHolder.threadTypeMessage.value
if (text.isEmpty() && getAttachmentSelections().isEmpty()) {
showErrorToast(getString(com.simplemobiletools.commons.R.string.unknown_error_occurred))
return
}
scrollToBottom()
text = removeDiacriticsIfNeeded(text)
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
}
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, conversation)
}
val conversation = conversationsDB.getConversationWithThreadId(threadId)
if (conversation != null) {
val nowSeconds = (System.currentTimeMillis() / 1000).toInt()
conversationsDB.insertOrUpdate(conversation.copy(date = nowSeconds, snippet = message.body))
}
scheduleMessage(message)
insertOrUpdateMessage(message)
runOnUiThread {
clearCurrentMessage()
hideScheduleSendUi()
scheduledMessage = null
}
}
} catch (e: Exception) {
showErrorToast(e.localizedMessage ?: getString(com.simplemobiletools.commons.R.string.unknown_error_occurred))
}
}
private fun sendNormalMessage(text: String, subscriptionId: Int) {
val addresses = participants.getAddresses()
val attachments = buildMessageAttachments()
try {
refreshedSinceSent = false
sendMessageCompat(text, addresses, subscriptionId, attachments, messageToResend)
ensureBackgroundThread {
val messageIds = messages.map { it.id }
val messages = getMessages(threadId, getImageResolutions = true, limit = maxOf(1, attachments.size))
.filter { it.id !in messageIds }
for (message in messages) {
insertOrUpdateMessage(message)
}
}
clearCurrentMessage()
} catch (e: Exception) {
showErrorToast(e)
} catch (e: Error) {
showErrorToast(e.localizedMessage ?: getString(com.simplemobiletools.commons.R.string.unknown_error_occurred))
}
}
private fun clearCurrentMessage() {
binding.messageHolder.threadTypeMessage.setText("")
getAttachmentsAdapter()?.clear()
checkSendMessageAvailability()
}
private fun insertOrUpdateMessage(message: Message) {
if (messages.map { it.id }.contains(message.id)) {
val messageToReplace = messages.find { it.id == message.id }
messages[messages.indexOf(messageToReplace)] = message
} else {
messages.add(message)
}
val newItems = getThreadItems()
runOnUiThread {
getOrCreateThreadAdapter().updateMessages(newItems, newItems.lastIndex)
if (!refreshedSinceSent) {
refreshMessages()
}
}
messagesDB.insertOrUpdate(message)
updateConversationArchivedStatus(message.threadId, false)
}
// show selected contacts, properly split to new lines when appropriate
// based on https://stackoverflow.com/a/13505029/1967672
private fun showSelectedContact(views: ArrayList<View>) {
binding.selectedContacts.removeAllViews()
var newLinearLayout = LinearLayout(this)
newLinearLayout.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
newLinearLayout.orientation = LinearLayout.HORIZONTAL
val sideMargin = (binding.selectedContacts.layoutParams as RelativeLayout.LayoutParams).leftMargin
val mediumMargin = resources.getDimension(com.simplemobiletools.commons.R.dimen.medium_margin).toInt()
val parentWidth = realScreenSize.x - sideMargin * 2
val firstRowWidth = parentWidth - resources.getDimension(com.simplemobiletools.commons.R.dimen.normal_icon_size).toInt() + sideMargin / 2
var widthSoFar = 0
var isFirstRow = true
for (i in views.indices) {
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)
layout.addView(views[i], params)
layout.measure(0, 0)
widthSoFar += views[i].measuredWidth + mediumMargin
val checkWidth = if (isFirstRow) firstRowWidth else parentWidth
if (widthSoFar >= checkWidth) {
isFirstRow = false
binding.selectedContacts.addView(newLinearLayout)
newLinearLayout = LinearLayout(this)
newLinearLayout.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
newLinearLayout.orientation = LinearLayout.HORIZONTAL
params = LayoutParams(layout.measuredWidth, layout.measuredHeight)
params.topMargin = mediumMargin
newLinearLayout.addView(layout, params)
widthSoFar = layout.measuredWidth
} else {
if (!isFirstRow) {
(layout.layoutParams as LayoutParams).topMargin = mediumMargin
}
newLinearLayout.addView(layout)
}
}
binding.selectedContacts.addView(newLinearLayout)
}
private fun removeSelectedContact(id: Int) {
participants = participants.filter { it.rawId != id }.toMutableList() as ArrayList<SimpleContact>
showSelectedContacts()
updateMessageType()
}
private fun getPhoneNumbersFromIntent(): ArrayList<String> {
val numberFromIntent = intent.getStringExtra(THREAD_NUMBER)
val numbers = ArrayList<String>()
if (numberFromIntent != null) {
if (numberFromIntent.startsWith('[') && numberFromIntent.endsWith(']')) {
val type = object : TypeToken<List<String>>() {}.type
numbers.addAll(Gson().fromJson(numberFromIntent, type))
} else {
numbers.add(numberFromIntent)
}
}
return numbers
}
private fun fixParticipantNumbers(participants: ArrayList<SimpleContact>, properNumbers: ArrayList<String>): ArrayList<SimpleContact> {
for (number in properNumbers) {
for (participant in participants) {
participant.phoneNumbers = participant.phoneNumbers.map {
val numberWithoutPlus = number.replace("+", "")
if (numberWithoutPlus == it.normalizedNumber.trim()) {
if (participant.name == it.normalizedNumber) {
participant.name = number
}
PhoneNumber(number, 0, "", number)
} else {
PhoneNumber(it.normalizedNumber, 0, "", it.normalizedNumber)
}
} as ArrayList<PhoneNumber>
}
}
return participants
}
fun saveMMS(mimeType: String, path: String) {
hideKeyboard()
lastAttachmentUri = path
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
type = mimeType
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(Intent.EXTRA_TITLE, path.split("/").last())
launchActivityForResult(this, PICK_SAVE_FILE_INTENT, error = com.simplemobiletools.commons.R.string.system_service_disabled)
}
}
@Subscribe(threadMode = ThreadMode.ASYNC)
fun refreshMessages(event: Events.RefreshMessages) {
if (isRecycleBin) {
return
}
refreshedSinceSent = true
allMessagesFetched = false
oldestMessageDate = -1
if (isActivityVisible) {
notificationManager.cancel(threadId.hashCode())
}
val lastMaxId = messages.filterNot { it.isScheduled }.maxByOrNull { it.id }?.id ?: 0L
val newThreadId = getThreadId(participants.getAddresses().toSet())
val newMessages = getMessages(newThreadId, getImageResolutions = true, includeScheduledMessages = false)
if (messages.isNotEmpty() && messages.all { it.isScheduled } && newMessages.isNotEmpty()) {
// update scheduled messages with real thread id
threadId = newThreadId
updateScheduledMessagesThreadId(messages = messages.filter { it.threadId != threadId }, threadId)
}
messages = newMessages.apply {
val scheduledMessages = messagesDB.getScheduledThreadMessages(threadId)
.filterNot { it.isScheduled && it.millis() < System.currentTimeMillis() }
addAll(scheduledMessages)
if (config.useRecycleBin) {
val recycledMessages = messagesDB.getThreadMessagesFromRecycleBin(threadId).toSet()
removeAll(recycledMessages)
}
}
messages.filter { !it.isScheduled && !it.isReceivedMessage() && it.id > lastMaxId }.forEach { latestMessage ->
messagesDB.insertOrIgnore(latestMessage)
}
setupAdapter()
runOnUiThread {
setupSIMSelector()
}
}
private fun isMmsMessage(text: String): Boolean {
val isGroupMms = participants.size > 1 && config.sendGroupMessageMMS
val isLongMmsMessage = isLongMmsMessage(text)
return getAttachmentSelections().isNotEmpty() || isGroupMms || isLongMmsMessage
}
private fun updateMessageType() {
val text = binding.messageHolder.threadTypeMessage.text.toString()
val stringId = if (isMmsMessage(text)) {
R.string.mms
} else {
R.string.sms
}
binding.messageHolder.threadSendMessage.setText(stringId)
}
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(com.simplemobiletools.commons.R.string.delete))
)
RadioGroupDialog(activity = this, items = items, titleId = R.string.scheduled_message) { any ->
when (any as Int) {
TYPE_DELETE -> cancelScheduledMessageAndRefresh(message.id)
TYPE_EDIT -> editScheduledMessage(message)
TYPE_SEND -> {
messages.removeAll { message.id == it.id }
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()
binding.messageHolder.threadTypeMessage.setText(message.body)
extractAttachments(message)
scheduledDateTime = DateTime(message.millis())
showScheduleMessageDialog()
}
private fun cancelScheduledMessageAndRefresh(messageId: Long) {
ensureBackgroundThread {
deleteScheduledMessage(messageId)
cancelScheduleSendPendingIntent(messageId)
refreshMessages()
}
}
private fun launchScheduleSendDialog(originalDateTime: DateTime? = null) {
askForExactAlarmPermissionIfNeeded {
ScheduleMessageDialog(this, originalDateTime) { newDateTime ->
if (newDateTime != null) {
scheduledDateTime = newDateTime
showScheduleMessageDialog()
}
}
}
}
private fun setupScheduleSendUi() = binding.messageHolder.apply {
val textColor = getProperTextColor()
scheduledMessageHolder.background.applyColorFilter(getProperPrimaryColor().darkenColor())
scheduledMessageIcon.applyColorFilter(textColor)
scheduledMessageButton.apply {
setTextColor(textColor)
setOnClickListener {
launchScheduleSendDialog(scheduledDateTime)
}
}
discardScheduledMessage.apply {
applyColorFilter(textColor)
setOnClickListener {
hideScheduleSendUi()
if (scheduledMessage != null) {
cancelScheduledMessageAndRefresh(scheduledMessage!!.id)
scheduledMessage = null
}
}
}
}
private fun showScheduleMessageDialog() {
isScheduledMessage = true
updateSendButtonDrawable()
binding.messageHolder.scheduledMessageHolder.beVisible()
val dateTime = scheduledDateTime
val millis = dateTime.millis
binding.messageHolder.scheduledMessageButton.text = if (dateTime.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
binding.messageHolder.scheduledMessageHolder.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())
binding.messageHolder.threadSendMessage.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 = MessageAttachment(messageId, text, buildMessageAttachments(messageId)),
senderPhoneNumber = "",
senderName = "",
senderPhotoUri = "",
subscriptionId = subscriptionId,
isScheduled = true
)
}
private fun buildMessageAttachments(messageId: Long = -1L) = getAttachmentSelections()
.map { Attachment(null, messageId, it.uri.toString(), it.mimetype, 0, 0, it.filename) }
.toArrayList()
private fun setupAttachmentPickerView() = binding.messageHolder.attachmentPicker.apply {
val buttonColors = arrayOf(
com.simplemobiletools.commons.R.color.md_red_500,
com.simplemobiletools.commons.R.color.md_brown_500,
com.simplemobiletools.commons.R.color.md_pink_500,
com.simplemobiletools.commons.R.color.md_purple_500,
com.simplemobiletools.commons.R.color.md_teal_500,
com.simplemobiletools.commons.R.color.md_green_500,
com.simplemobiletools.commons.R.color.md_indigo_500,
com.simplemobiletools.commons.R.color.md_blue_500
).map { ResourcesCompat.getColor(resources, it, theme) }
arrayOf(
choosePhotoIcon,
chooseVideoIcon,
takePhotoIcon,
recordVideoIcon,
recordAudioIcon,
pickFileIcon,
pickContactIcon,
scheduleMessageIcon
).forEachIndexed { index, icon ->
val iconColor = buttonColors[index]
icon.background.applyColorFilter(iconColor)
icon.applyColorFilter(iconColor.getContrastColor())
}
val textColor = getProperTextColor()
arrayOf(
choosePhotoText,
chooseVideoText,
takePhotoText,
recordVideoText,
recordAudioText,
pickFileText,
pickContactText,
scheduleMessageText
).forEach { it.setTextColor(textColor) }
choosePhoto.setOnClickListener {
launchGetContentIntent(arrayOf("image/*"), PICK_PHOTO_INTENT)
}
chooseVideo.setOnClickListener {
launchGetContentIntent(arrayOf("video/*"), PICK_VIDEO_INTENT)
}
takePhoto.setOnClickListener {
launchCapturePhotoIntent()
}
recordVideo.setOnClickListener {
launchCaptureVideoIntent()
}
recordAudio.setOnClickListener {
launchCaptureAudioIntent()
}
pickFile.setOnClickListener {
launchGetContentIntent(arrayOf("*/*"), PICK_DOCUMENT_INTENT)
}
pickContact.setOnClickListener {
launchPickContactIntent()
}
scheduleMessage.setOnClickListener {
if (isScheduledMessage) {
launchScheduleSendDialog(scheduledDateTime)
} else {
launchScheduleSendDialog()
}
}
}
private fun showAttachmentPicker() {
binding.messageHolder.attachmentPickerDivider.showWithAnimation()
binding.messageHolder.attachmentPickerHolder.showWithAnimation()
animateAttachmentButton(rotation = -135f)
}
private fun maybeSetupRecycleBinView() {
if (isRecycleBin) {
binding.messageHolder.root.beGone()
}
}
private fun hideAttachmentPicker() {
binding.messageHolder.attachmentPickerDivider.beGone()
binding.messageHolder.attachmentPickerHolder.apply {
beGone()
updateLayoutParams<ConstraintLayout.LayoutParams> {
height = config.keyboardHeight
}
}
animateAttachmentButton(rotation = 0f)
}
private fun animateAttachmentButton(rotation: Float) {
binding.messageHolder.threadAddAttachment.animate()
.rotation(rotation)
.setDuration(500L)
.setInterpolator(OvershootInterpolator())
.start()
}
private fun setupKeyboardListener() {
window.decorView.setOnApplyWindowInsetsListener { view, insets ->
showOrHideAttachmentPicker()
view.onApplyWindowInsets(insets)
}
val callback = object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_CONTINUE_ON_SUBTREE) {
override fun onPrepare(animation: WindowInsetsAnimationCompat) {
super.onPrepare(animation)
showOrHideAttachmentPicker()
}
override fun onProgress(insets: WindowInsetsCompat, runningAnimations: MutableList<WindowInsetsAnimationCompat>) = insets
}
ViewCompat.setWindowInsetsAnimationCallback(window.decorView, callback)
}
private fun showOrHideAttachmentPicker() {
val type = WindowInsetsCompat.Type.ime()
val insets = ViewCompat.getRootWindowInsets(window.decorView) ?: return
val isKeyboardVisible = insets.isVisible(type)
if (isKeyboardVisible) {
val keyboardHeight = insets.getInsets(type).bottom
val bottomBarHeight = insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom
// check keyboard height just to be sure, 150 seems like a good middle ground between ime and navigation bar
config.keyboardHeight = if (keyboardHeight > 150) {
keyboardHeight - bottomBarHeight
} else {
getDefaultKeyboardHeight()
}
hideAttachmentPicker()
} else if (isAttachmentPickerVisible) {
showAttachmentPicker()
}
}
private fun getBottomBarColor() = if (baseConfig.isUsingSystemTheme) {
resources.getColor(com.simplemobiletools.commons.R.color.you_bottom_bar_color)
} else {
getBottomNavigationBackgroundColor()
}
}