package com.simplemobiletools.smsmessenger.activities import android.annotation.SuppressLint import android.app.Activity import android.content.ActivityNotFoundException import android.content.Intent 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.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 androidx.annotation.StringRes import androidx.appcompat.widget.AppCompatButton import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.res.ResourcesCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.children import androidx.core.view.updateLayoutParams import com.google.gson.Gson import com.google.gson.reflect.TypeToken import com.simplemobiletools.commons.dialogs.ConfirmationDialog 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.R import com.simplemobiletools.smsmessenger.adapters.AttachmentsAdapter import com.simplemobiletools.smsmessenger.adapters.AutoCompleteTextViewAdapter import com.simplemobiletools.smsmessenger.adapters.ThreadAdapter import com.simplemobiletools.smsmessenger.dialogs.ScheduleMessageDialog import com.simplemobiletools.smsmessenger.extensions.* import com.simplemobiletools.smsmessenger.helpers.* import com.simplemobiletools.smsmessenger.models.* import kotlinx.android.synthetic.main.activity_thread.* import kotlinx.android.synthetic.main.item_selected_contact.view.* import kotlinx.android.synthetic.main.layout_attachment_picker.* 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 var threadId = 0L private var currentSIMCardIndex = 0 private var isActivityVisible = false private var refreshedSinceSent = false private var threadItems = ArrayList() private var bus: EventBus? = null private var participants = ArrayList() private var privateContacts = ArrayList() private var messages = ArrayList() private val availableSIMCards = ArrayList() private var lastAttachmentUri: String? = null private var capturedImageUri: Uri? = null private var loadingOlderMessages = false private var allMessagesFetched = false private var oldestMessageDate = -1 private var isScheduledMessage: Boolean = false private var scheduledMessage: Message? = null private lateinit var scheduledDateTime: DateTime private var isAttachmentPickerVisible = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_thread) setupOptionsMenu() refreshMenuItems() val extras = intent.extras if (extras == null) { toast(R.string.unknown_error_occurred) finish() return } clearAllMessagesIfNeeded() threadId = intent.getLongExtra(THREAD_ID, 0L) intent.getStringExtra(THREAD_TITLE)?.let { thread_toolbar.title = it } bus = EventBus.getDefault() bus!!.register(this) handlePermission(PERMISSION_READ_PHONE_STATE) { if (it) { setupButtons() 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) { thread_messages_list.smoothScrollToPosition(index) } } setupThread() } } else { finish() } } setupAttachmentPickerView() setupKeyboardListener() hideAttachmentPicker() } override fun onResume() { super.onResume() setupToolbar(thread_toolbar, NavigationIcon.Arrow) updateNavigationBarColor(getBottomNavigationBackgroundColor()) val smsDraft = getSmsDraft(threadId) if (smsDraft != null) { thread_type_message.setText(smsDraft) } isActivityVisible = true } override fun onPause() { super.onPause() if (thread_type_message.value != "" && getAttachmentSelections().isEmpty()) { saveSmsDraft(thread_type_message.value, threadId) } else { deleteSmsDraft(threadId) } bus?.post(Events.RefreshMessages()) isActivityVisible = false } override fun onBackPressed() { isAttachmentPickerVisible = false if (attachment_picker_holder.isVisible()) { hideAttachmentPicker() } else { super.onBackPressed() } } override fun onDestroy() { super.onDestroy() bus?.unregister(this) } private fun refreshMenuItems() { val firstPhoneNumber = participants.firstOrNull()?.phoneNumbers?.firstOrNull()?.value thread_toolbar.menu.apply { findItem(R.id.delete).isVisible = threadItems.isNotEmpty() findItem(R.id.block_number).isVisible = isNougatPlus() findItem(R.id.dial_number).isVisible = participants.size == 1 findItem(R.id.mark_as_unread).isVisible = threadItems.isNotEmpty() // 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() } } } private fun setupOptionsMenu() { thread_toolbar.setOnMenuItemClickListener { menuItem -> if (participants.isEmpty()) { return@setOnMenuItemClickListener true } when (menuItem.itemId) { R.id.block_number -> blockNumber() R.id.delete -> askConfirmDelete() 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 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_VIDEO_INTENT -> addAttachment(data) PICK_CONTACT_INTENT -> addContactAttachment(data) PICK_SAVE_FILE_INTENT -> saveAttachment(resultData) } } } private fun setupCachedMessages(callback: () -> Unit) { ensureBackgroundThread { messages = try { messagesDB.getThreadMessages(threadId).toMutableList() as ArrayList } 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) thread_type_message.requestFocus() } setupThreadTitle() setupSIMSelector() updateMessageType() callback() } } } private fun setupThread() { val privateCursor = getMyContactsCursor(false, true) ensureBackgroundThread { privateContacts = MyContactsContentProvider.getSimpleContacts(this, privateCursor) val cachedMessagesCode = messages.clone().hashCode() messages = getMessages(threadId, true) 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() 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(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) } messages.chunked(30).forEach { currentMessages -> messagesDB.insertMessages(*currentMessages.toTypedArray()) } setupAttachmentSizes() setupAdapter() runOnUiThread { setupThreadTitle() setupSIMSelector() } } } private fun setupAdapter() { threadItems = getThreadItems() runOnUiThread { refreshMenuItems() val currAdapter = thread_messages_list.adapter if (currAdapter == null) { ThreadAdapter( activity = this, messages = threadItems, recyclerView = thread_messages_list, itemClick = { handleItemClick(it) }, onThreadIdUpdate = { threadId = it } ).apply { thread_messages_list.adapter = this } thread_messages_list.endlessScrollListener = object : MyRecyclerView.EndlessScrollListener { override fun updateBottom() {} override fun updateTop() { fetchNextMessages() } } } else { (currAdapter as ThreadAdapter).updateMessages(threadItems) } } SimpleContactsHelper(this).getAvailableContacts(false) { contacts -> contacts.addAll(privateContacts) runOnUiThread { val adapter = AutoCompleteTextViewAdapter(this, contacts) add_contact_or_number.setAdapter(adapter) add_contact_or_number.imeOptions = EditorInfo.IME_ACTION_NEXT add_contact_or_number.setOnItemClickListener { _, _, position, _ -> val currContacts = (add_contact_or_number.adapter as AutoCompleteTextViewAdapter).resultList val selectedContact = currContacts[position] addSelectedContact(selectedContact) } add_contact_or_number.onTextChangeListener { confirm_inserted_number.beVisibleIf(it.length > 2) } } } runOnUiThread { confirm_inserted_number?.setOnClickListener { val number = add_contact_or_number.value val phoneNumber = PhoneNumber(number, 0, "", number) val contact = SimpleContact(number.hashCode(), number.hashCode(), number, "", arrayListOf(phoneNumber), ArrayList(), ArrayList()) addSelectedContact(contact) } } } 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 } val dateOfFirstItem = messages.first().date if (oldestMessageDate == dateOfFirstItem) { allMessagesFetched = true return } oldestMessageDate = dateOfFirstItem loadingOlderMessages = true ensureBackgroundThread { val firstItem = messages.first() val olderMessages = getMessages(threadId, true, oldestMessageDate) .filter { message -> !messages.contains(message) } messages.addAll(0, olderMessages) threadItems = getThreadItems() allMessagesFetched = olderMessages.size < MESSAGES_LIMIT || olderMessages.isEmpty() runOnUiThread { loadingOlderMessages = false val itemAtRefreshIndex = threadItems.indexOfFirst { it == firstItem } (thread_messages_list.adapter as ThreadAdapter).apply { updateMessages(threadItems, itemAtRefreshIndex) } } } } private fun setupButtons() { updateTextColors(thread_holder) val textColor = getProperTextColor() thread_send_message.apply { setTextColor(textColor) compoundDrawables.forEach { it?.applyColorFilter(textColor) } } confirm_manage_contacts.applyColorFilter(textColor) thread_add_attachment.applyColorFilter(textColor) val properPrimaryColor = getProperPrimaryColor() thread_messages_fastscroller.updateColors(properPrimaryColor) thread_character_counter.beVisibleIf(config.showCharacterCounter) thread_character_counter.setTextSize(TypedValue.COMPLEX_UNIT_PX, getTextSize()) thread_type_message.setTextSize(TypedValue.COMPLEX_UNIT_PX, getTextSize()) thread_send_message.setOnClickListener { sendMessage() } thread_send_message.setOnLongClickListener { if (!isScheduledMessage) { launchScheduleSendDialog() } true } thread_send_message.isClickable = false thread_type_message.onTextChangeListener { checkSendMessageAvailability() val messageString = if (config.useSimpleCharacters) it.normalizeString() else it val messageLength = SmsMessage.calculateLength(messageString, false) thread_character_counter.text = "${messageLength[2]}/${messageLength[0]}" } confirm_manage_contacts.setOnClickListener { hideKeyboard() thread_add_contacts.beGone() val numbers = HashSet() participants.forEach { contact -> contact.phoneNumbers.forEach { numbers.add(it.normalizedNumber) } } val newThreadId = getThreadId(numbers) if (threadId != newThreadId) { hideKeyboard() Intent(this, ThreadActivity::class.java).apply { putExtra(THREAD_ID, newThreadId) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) startActivity(this) } } } thread_type_message.setText(intent.getStringExtra(THREAD_TEXT)) thread_add_attachment.setOnClickListener { if (attachment_picker_holder.isVisible()) { isAttachmentPickerVisible = false showKeyboard(thread_type_message) } else { isAttachmentPickerVisible = true hideKeyboard() } 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)?.forEach { addAttachment(it) } } setupScheduleSendUi() } 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 } } } private fun setupThreadTitle() { val threadTitle = participants.getThreadTitle() if (threadTitle.isNotEmpty()) { thread_toolbar.title = 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() participants.forEach { contact -> contact.phoneNumbers.forEach { numbers.add(it.normalizedNumber) } } if (numbers.isEmpty()) { return } currentSIMCardIndex = getProperSimIndex(availableSIMs, numbers) thread_select_sim_icon.applyColorFilter(getProperTextColor()) thread_select_sim_icon.beVisible() thread_select_sim_number.beVisible() if (availableSIMCards.isNotEmpty()) { thread_select_sim_icon.setOnClickListener { currentSIMCardIndex = (currentSIMCardIndex + 1) % availableSIMCards.size val currentSIMCard = availableSIMCards[currentSIMCardIndex] thread_select_sim_number.text = currentSIMCard.id.toString() val currentSubscriptionId = currentSIMCard.subscriptionId numbers.forEach { config.saveUseSIMIdAtNumber(it, currentSubscriptionId) } toast(currentSIMCard.label) } } thread_select_sim_number.setTextColor(getProperTextColor().getContrastColor()) try { thread_select_sim_number.text = (availableSIMCards[currentSIMCardIndex].id).toString() } catch (e: Exception) { showErrorToast(e) } } } @SuppressLint("MissingPermission") private fun getProperSimIndex(availableSIMs: MutableList, numbers: List): 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 blockNumber() { val numbers = participants.getAddresses() val numbersString = TextUtils.join(", ", numbers) val question = String.format(resources.getString(R.string.block_confirmation), numbersString) ConfirmationDialog(this, question) { ensureBackgroundThread { numbers.forEach { addBlockedNumber(it) } refreshMessages() finish() } } } private fun askConfirmDelete() { ConfirmationDialog(this, getString(R.string.delete_whole_conversation_confirmation)) { ensureBackgroundThread { deleteConversation(threadId) runOnUiThread { refreshMessages() finish() } } } } private fun dialNumber() { val phoneNumber = participants.first().phoneNumbers.first().normalizedNumber dialNumber(phoneNumber) } private fun managePeople() { if (thread_add_contacts.isVisible()) { hideKeyboard() thread_add_contacts.beGone() } else { showSelectedContacts() thread_add_contacts.beVisible() add_contact_or_number.requestFocus() showKeyboard(add_contact_or_number) } } private fun showSelectedContacts() { val properPrimaryColor = getProperPrimaryColor() val views = ArrayList() participants.forEach { contact -> layoutInflater.inflate(R.layout.item_selected_contact, null).apply { val selectedContactBg = resources.getDrawable(R.drawable.item_selected_contact_background) (selectedContactBg as LayerDrawable).findDrawableByLayerId(R.id.selected_contact_bg).applyColorFilter(properPrimaryColor) selected_contact_holder.background = selectedContactBg selected_contact_name.text = contact.name selected_contact_name.setTextColor(properPrimaryColor.getContrastColor()) selected_contact_remove.applyColorFilter(properPrimaryColor.getContrastColor()) selected_contact_remove.setOnClickListener { if (contact.rawId != participants.first().rawId) { removeSelectedContact(contact.rawId) } } views.add(this) } } showSelectedContact(views) } private fun addSelectedContact(contact: SimpleContact) { add_contact_or_number.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) } } @SuppressLint("MissingPermission") private fun getThreadItems(): ArrayList { val items = ArrayList() if (isFinishing) { return items } messages.sortBy { it.date } val subscriptionIdToSimId = HashMap() 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()) } return items } private fun launchActivityForResult(intent: Intent, requestCode: Int, @StringRes error: Int = 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 launchPickPhotoVideoIntent() { val mimeTypes = arrayOf("image/*", "video/*") Intent(Intent.ACTION_GET_CONTENT).apply { addCategory(Intent.CATEGORY_OPENABLE) type = "*/*" putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes) launchActivityForResult(this, PICK_PHOTO_VIDEO_INTENT) } } private fun launchPickDocumentIntent() { val mimeTypes = arrayOf("*/*") Intent(Intent.ACTION_GET_CONTENT).apply { addCategory(Intent.CATEGORY_OPENABLE) type = "*/*" putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes) launchActivityForResult(this, PICK_DOCUMENT_INTENT) } } private fun launchPickContactIntent() { Intent(Intent.ACTION_PICK).apply { type = ContactsContract.CommonDataKinds.Phone.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 == VcfExporter.ExportResult.EXPORT_OK) { val vCardUri = getMyFileUri(outputFile) runOnUiThread { addAttachment(vCardUri) } } else { toast(R.string.unknown_error_occurred) } } } else { toast(R.string.unknown_error_occurred) } } } private fun getAttachmentsAdapter(): AttachmentsAdapter? { val adapter = thread_attachments_recyclerview.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 } var adapter = getAttachmentsAdapter() if (adapter == null) { adapter = AttachmentsAdapter( activity = this, recyclerView = thread_attachments_recyclerview, onAttachmentsRemoved = { thread_attachments_recyclerview.beGone() checkSendMessageAvailability() }, onReady = { checkSendMessageAvailability() } ) thread_attachments_recyclerview.adapter = adapter } thread_attachments_recyclerview.beVisible() val mimeType = contentResolver.getType(uri).orEmpty() val attachment = AttachmentSelection( id = id, uri = uri, mimetype = mimeType, filename = getFilenameFromUri(uri), isPending = mimeType.isImageMimeType() && !mimeType.isGifMimeType() ) 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(R.string.file_saved) } catch (e: Exception) { showErrorToast(e) } finally { inputStream?.close() outputStream?.close() } lastAttachmentUri = null } private fun checkSendMessageAvailability() { if (thread_type_message.text!!.isNotEmpty() || (getAttachmentSelections().isNotEmpty() && !getAttachmentSelections().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 } updateMessageType() } private fun sendMessage() { var text = thread_type_message.value if (text.isEmpty() && getAttachmentSelections().isEmpty()) { showErrorToast(getString(R.string.unknown_error_occurred)) return } 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) } 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) runOnUiThread { 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 = buildMessageAttachments() try { refreshedSinceSent = false sendMessage(text, addresses, subscriptionId, attachments) clearCurrentMessage() if (!refreshedSinceSent) { refreshMessages() } } catch (e: Exception) { showErrorToast(e) } catch (e: Error) { showErrorToast(e.localizedMessage ?: getString(R.string.unknown_error_occurred)) } } private fun clearCurrentMessage() { thread_type_message.setText("") getAttachmentsAdapter()?.clear() checkSendMessageAvailability() } // show selected contacts, properly split to new lines when appropriate // based on https://stackoverflow.com/a/13505029/1967672 private fun showSelectedContact(views: ArrayList) { selected_contacts.removeAllViews() var newLinearLayout = LinearLayout(this) newLinearLayout.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) newLinearLayout.orientation = LinearLayout.HORIZONTAL val sideMargin = (selected_contacts.layoutParams as RelativeLayout.LayoutParams).leftMargin val mediumMargin = resources.getDimension(R.dimen.medium_margin).toInt() val parentWidth = realScreenSize.x - sideMargin * 2 val firstRowWidth = parentWidth - resources.getDimension(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 selected_contacts.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) } } selected_contacts.addView(newLinearLayout) } private fun removeSelectedContact(id: Int) { participants = participants.filter { it.rawId != id }.toMutableList() as ArrayList showSelectedContacts() updateMessageType() } private fun getPhoneNumbersFromIntent(): ArrayList { val numberFromIntent = intent.getStringExtra(THREAD_NUMBER) val numbers = ArrayList() if (numberFromIntent != null) { if (numberFromIntent.startsWith('[') && numberFromIntent.endsWith(']')) { val type = object : TypeToken>() {}.type numbers.addAll(Gson().fromJson(numberFromIntent, type)) } else { numbers.add(numberFromIntent) } } return numbers } private fun fixParticipantNumbers(participants: ArrayList, properNumbers: ArrayList): ArrayList { 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 } } return participants } fun startContactDetailsIntent(contact: SimpleContact) { val simpleContacts = "com.simplemobiletools.contacts.pro" val simpleContactsDebug = "com.simplemobiletools.contacts.pro.debug" if (contact.rawId > 1000000 && contact.contactId > 1000000 && contact.rawId == contact.contactId && (isPackageInstalled(simpleContacts) || isPackageInstalled(simpleContactsDebug)) ) { Intent().apply { action = Intent.ACTION_VIEW putExtra(CONTACT_ID, contact.rawId) putExtra(IS_PRIVATE, true) setPackage(if (isPackageInstalled(simpleContacts)) simpleContacts else simpleContactsDebug) setDataAndType(ContactsContract.Contacts.CONTENT_LOOKUP_URI, "vnd.android.cursor.dir/person") launchActivityIntent(this) } } else { ensureBackgroundThread { val lookupKey = SimpleContactsHelper(this).getContactLookupKey((contact).rawId.toString()) val publicUri = Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_LOOKUP_URI, lookupKey) runOnUiThread { launchViewContactIntent(publicUri) } } } } 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 = R.string.system_service_disabled) } } @SuppressLint("MissingPermission") @Subscribe(threadMode = ThreadMode.ASYNC) fun refreshMessages(event: Events.RefreshMessages) { refreshedSinceSent = true allMessagesFetched = false oldestMessageDate = -1 if (isActivityVisible) { notificationManager.cancel(threadId.hashCode()) } 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 subscriptionId = availableSIMCards.getOrNull(currentSIMCardIndex)?.subscriptionId if (subscriptionId != null) { updateMessageSubscriptionId(latestMessage.id, subscriptionId) latestMessage.subscriptionId = subscriptionId } } messagesDB.insertOrIgnore(latestMessage) } setupAdapter() setupSIMSelector() } private fun isMmsMessage(text: String): Boolean { val isGroupMms = participants.size > 1 && config.sendGroupMessageMMS val isLongMmsMessage = isLongMmsMessage(text) && config.sendLongMessageMMS return getAttachmentSelections().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 } thread_send_message.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(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()) showScheduleMessageDialog() } private fun cancelScheduledMessageAndRefresh(messageId: Long) { ensureBackgroundThread { deleteScheduledMessage(messageId) cancelScheduleSendPendingIntent(messageId) refreshMessages() } } private fun launchScheduleSendDialog(originalDateTime: DateTime? = null) { ScheduleMessageDialog(this, originalDateTime) { newDateTime -> if (newDateTime != null) { scheduledDateTime = newDateTime showScheduleMessageDialog() } } } private fun setupScheduleSendUi() { val textColor = getProperTextColor() scheduled_message_holder.background.applyColorFilter(getProperPrimaryColor().darkenColor()) 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 showScheduleMessageDialog() { isScheduledMessage = true updateSendButtonDrawable() scheduled_message_holder.beVisible() val dateTime = scheduledDateTime val millis = dateTime.millis scheduled_message_button.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 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 = MessageAttachment(messageId, text, buildMessageAttachments(messageId)), 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() { val colors = arrayOf( R.color.md_red_500, R.color.md_pink_500, R.color.md_purple_500, R.color.md_teal_500, R.color.md_green_500, R.color.md_light_green_500, R.color.md_blue_500 ) attachment_picker_holder.children.filterIsInstance().forEachIndexed { index, button -> button.setTextColor(getProperTextColor()) val color = resources.getColor(colors[index]) button.compoundDrawables.forEach { it?.applyColorFilter(color) } } pick_from_gallery.setOnClickListener { launchPickPhotoVideoIntent() } camera.setOnClickListener { launchCapturePhotoIntent() } record_video.setOnClickListener { launchCaptureVideoIntent() } record_audio.setOnClickListener { launchCaptureAudioIntent() } pick_file.setOnClickListener { launchPickDocumentIntent() } pick_contact.setOnClickListener { launchPickContactIntent() } schedule_message.setOnClickListener { if (isScheduledMessage) { launchScheduleSendDialog(scheduledDateTime) } else { launchScheduleSendDialog() } } } private fun showAttachmentPicker() { attachment_picker_divider.showWithAnimation() attachment_picker_holder.showWithAnimation() animateAttachmentButton(rotation = -135f) } private fun hideAttachmentPicker() { attachment_picker_divider.beGone() attachment_picker_holder.apply { beGone() updateLayoutParams { height = config.keyboardHeight } } animateAttachmentButton(rotation = 0f) } private fun animateAttachmentButton(rotation: Float) { thread_add_attachment.animate() .rotation(rotation) .setDuration(500L) .setInterpolator(OvershootInterpolator()) .start() } private fun setupKeyboardListener() { val imeTypeMask = WindowInsetsCompat.Type.ime() val navigationBarMask = WindowInsetsCompat.Type.navigationBars() window.decorView.setOnApplyWindowInsetsListener { view, windowInsets -> val insets = WindowInsetsCompat.toWindowInsetsCompat(windowInsets) if (insets.isVisible(imeTypeMask)) { config.keyboardHeight = insets.getInsets(imeTypeMask).bottom - insets.getInsets(navigationBarMask).bottom hideAttachmentPicker() } else if (isAttachmentPickerVisible) { showAttachmentPicker() } view.onApplyWindowInsets(windowInsets) } } }