package com.simplemobiletools.smsmessenger.activities import android.annotation.SuppressLint import android.app.Activity import android.app.role.RoleManager import android.content.ActivityNotFoundException import android.content.Intent import android.content.pm.ShortcutInfo import android.content.pm.ShortcutManager import android.graphics.drawable.Icon import android.graphics.drawable.LayerDrawable import android.net.Uri import android.os.Bundle import android.provider.Telephony import android.text.TextUtils import android.widget.Toast import androidx.coordinatorlayout.widget.CoordinatorLayout import com.simplemobiletools.commons.dialogs.FilePickerDialog import com.simplemobiletools.commons.dialogs.PermissionRequiredDialog import com.simplemobiletools.commons.extensions.* import com.simplemobiletools.commons.helpers.* import com.simplemobiletools.commons.models.FAQItem import com.simplemobiletools.commons.models.Release import com.simplemobiletools.smsmessenger.BuildConfig import com.simplemobiletools.smsmessenger.R import com.simplemobiletools.smsmessenger.adapters.ConversationsAdapter import com.simplemobiletools.smsmessenger.adapters.SearchResultsAdapter import com.simplemobiletools.smsmessenger.dialogs.ExportMessagesDialog import com.simplemobiletools.smsmessenger.dialogs.ImportMessagesDialog import com.simplemobiletools.smsmessenger.extensions.* import com.simplemobiletools.smsmessenger.helpers.* import com.simplemobiletools.smsmessenger.models.Conversation import com.simplemobiletools.smsmessenger.models.Events import com.simplemobiletools.smsmessenger.models.Message import com.simplemobiletools.smsmessenger.models.SearchResult import kotlinx.android.synthetic.main.activity_main.* import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import java.io.FileOutputStream import java.io.OutputStream class MainActivity : SimpleActivity() { private val MAKE_DEFAULT_APP_REQUEST = 1 private val PICK_IMPORT_SOURCE_INTENT = 11 private val PICK_EXPORT_FILE_INTENT = 21 private var storedTextColor = 0 private var storedFontSize = 0 private var lastSearchedText = "" private var bus: EventBus? = null private val smsExporter by lazy { MessagesExporter(this) } private var wasProtectionHandled = false @SuppressLint("InlinedApi") override fun onCreate(savedInstanceState: Bundle?) { isMaterialActivity = true super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) appLaunched(BuildConfig.APPLICATION_ID) setupOptionsMenu() refreshMenuItems() updateMaterialActivityViews(main_coordinator, conversations_list, useTransparentNavigation = true, useTopSearchMenu = true) if (savedInstanceState == null) { checkAndDeleteOldRecycleBinMessages() handleAppPasswordProtection { wasProtectionHandled = it if (it) { loadMessages() } else { finish() } } } if (checkAppSideloading()) { return } clearAllMessagesIfNeeded() } override fun onResume() { super.onResume() updateMenuColors() getOrCreateConversationsAdapter().apply { if (storedTextColor != getProperTextColor()) { updateTextColor(getProperTextColor()) } if (storedFontSize != config.fontSize) { updateFontSize() } updateDrafts() } updateTextColors(main_coordinator) search_holder.setBackgroundColor(getProperBackgroundColor()) val properPrimaryColor = getProperPrimaryColor() no_conversations_placeholder_2.setTextColor(properPrimaryColor) no_conversations_placeholder_2.underlineText() conversations_fastscroller.updateColors(properPrimaryColor) conversations_progress_bar.setIndicatorColor(properPrimaryColor) conversations_progress_bar.trackColor = properPrimaryColor.adjustAlpha(LOWER_ALPHA) checkShortcut() (conversations_fab?.layoutParams as? CoordinatorLayout.LayoutParams)?.bottomMargin = navigationBarHeight + resources.getDimension(R.dimen.activity_margin).toInt() } override fun onPause() { super.onPause() storeStateVariables() } override fun onDestroy() { super.onDestroy() bus?.unregister(this) } override fun onBackPressed() { if (main_menu.isSearchOpen) { main_menu.closeSearch() } else { super.onBackPressed() } } 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) { loadMessages() } else { finish() } } } else { loadMessages() } } private fun setupOptionsMenu() { main_menu.getToolbar().inflateMenu(R.menu.menu_main) main_menu.toggleHideOnScroll(true) main_menu.setupMenu() main_menu.onSearchClosedListener = { fadeOutSearch() } main_menu.onSearchTextChangedListener = { text -> if (text.isNotEmpty()) { if (search_holder.alpha < 1f) { search_holder.fadeIn() } } else { fadeOutSearch() } searchTextChanged(text) } main_menu.getToolbar().setOnMenuItemClickListener { menuItem -> when (menuItem.itemId) { R.id.import_messages -> tryImportMessages() R.id.export_messages -> tryToExportMessages() R.id.more_apps_from_us -> launchMoreAppsFromUsIntent() R.id.show_recycle_bin -> launchRecycleBin() R.id.settings -> launchSettings() R.id.about -> launchAbout() else -> return@setOnMenuItemClickListener false } return@setOnMenuItemClickListener true } } private fun refreshMenuItems() { main_menu.getToolbar().menu.apply { findItem(R.id.more_apps_from_us).isVisible = !resources.getBoolean(R.bool.hide_google_relations) } } override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) { super.onActivityResult(requestCode, resultCode, resultData) if (requestCode == MAKE_DEFAULT_APP_REQUEST) { if (resultCode == Activity.RESULT_OK) { askPermissions() } else { finish() } } else if (requestCode == PICK_IMPORT_SOURCE_INTENT && resultCode == Activity.RESULT_OK && resultData != null && resultData.data != null) { tryImportMessagesFromFile(resultData.data!!) } else if (requestCode == PICK_EXPORT_FILE_INTENT && resultCode == Activity.RESULT_OK && resultData != null && resultData.data != null) { val outputStream = contentResolver.openOutputStream(resultData.data!!) exportMessagesTo(outputStream) } } private fun storeStateVariables() { storedTextColor = getProperTextColor() storedFontSize = config.fontSize } private fun updateMenuColors() { updateStatusbarColor(getProperBackgroundColor()) main_menu.updateColors() } private fun loadMessages() { if (isQPlus()) { val roleManager = getSystemService(RoleManager::class.java) if (roleManager!!.isRoleAvailable(RoleManager.ROLE_SMS)) { if (roleManager.isRoleHeld(RoleManager.ROLE_SMS)) { askPermissions() } else { val intent = roleManager.createRequestRoleIntent(RoleManager.ROLE_SMS) startActivityForResult(intent, MAKE_DEFAULT_APP_REQUEST) } } else { toast(R.string.unknown_error_occurred) finish() } } else { if (Telephony.Sms.getDefaultSmsPackage(this) == packageName) { askPermissions() } else { val intent = Intent(Telephony.Sms.Intents.ACTION_CHANGE_DEFAULT) intent.putExtra(Telephony.Sms.Intents.EXTRA_PACKAGE_NAME, packageName) startActivityForResult(intent, MAKE_DEFAULT_APP_REQUEST) } } } // while SEND_SMS and READ_SMS permissions are mandatory, READ_CONTACTS is optional. If we don't have it, we just won't be able to show the contact name in some cases private fun askPermissions() { handlePermission(PERMISSION_READ_SMS) { if (it) { handlePermission(PERMISSION_SEND_SMS) { if (it) { handlePermission(PERMISSION_READ_CONTACTS) { handleNotificationPermission { granted -> if (!granted) { PermissionRequiredDialog(this, R.string.allow_notifications_incoming_messages) } } initMessenger() bus = EventBus.getDefault() try { bus!!.register(this) } catch (e: Exception) { } } } else { finish() } } } else { finish() } } } private fun initMessenger() { checkWhatsNewDialog() storeStateVariables() getCachedConversations() no_conversations_placeholder_2.setOnClickListener { launchNewConversation() } conversations_fab.setOnClickListener { launchNewConversation() } } private fun getCachedConversations() { ensureBackgroundThread { val conversations = try { conversationsDB.getAll().toMutableList() as ArrayList } catch (e: Exception) { ArrayList() } updateUnreadCountBadge(conversations) runOnUiThread { setupConversations(conversations, cached = true) getNewConversations(conversations) } conversations.forEach { clearExpiredScheduledMessages(it.threadId) } } } private fun getNewConversations(cachedConversations: ArrayList) { val privateCursor = getMyContactsCursor(favoritesOnly = false, withPhoneNumbersOnly = true) ensureBackgroundThread { val privateContacts = MyContactsContentProvider.getSimpleContacts(this, privateCursor) val conversations = getConversations(privateContacts = privateContacts) conversations.forEach { clonedConversation -> val threadIds = cachedConversations.map { it.threadId } if (!threadIds.contains(clonedConversation.threadId)) { conversationsDB.insertOrUpdate(clonedConversation) cachedConversations.add(clonedConversation) } } cachedConversations.forEach { cachedConversation -> val threadId = cachedConversation.threadId val isTemporaryThread = cachedConversation.isScheduled val isConversationDeleted = !conversations.map { it.threadId }.contains(threadId) if (isConversationDeleted && !isTemporaryThread) { conversationsDB.deleteThreadId(threadId) } val newConversation = conversations.find { it.phoneNumber == cachedConversation.phoneNumber } if (isTemporaryThread && newConversation != null) { // delete the original temporary thread and move any scheduled messages to the new thread conversationsDB.deleteThreadId(threadId) messagesDB.getScheduledThreadMessages(threadId) .forEach { message -> messagesDB.insertOrUpdate(message.copy(threadId = newConversation.threadId)) } insertOrUpdateConversation(newConversation, cachedConversation) } } cachedConversations.forEach { cachedConv -> val conv = conversations.find { it.threadId == cachedConv.threadId && !Conversation.areContentsTheSame(cachedConv, it) } if (conv != null) { val lastModified = maxOf(cachedConv.date, conv.date) val conversation = conv.copy(date = lastModified) insertOrUpdateConversation(conversation) } } val allConversations = conversationsDB.getAll() as ArrayList runOnUiThread { setupConversations(allConversations) } if (config.appRunCount == 1) { conversations.map { it.threadId }.forEach { threadId -> val messages = getMessages(threadId, getImageResolutions = false, includeScheduledMessages = false) messages.chunked(30).forEach { currentMessages -> messagesDB.insertMessages(*currentMessages.toTypedArray()) } } } } } private fun getOrCreateConversationsAdapter(): ConversationsAdapter { var currAdapter = conversations_list.adapter if (currAdapter == null) { hideKeyboard() currAdapter = ConversationsAdapter( activity = this, recyclerView = conversations_list, onRefresh = { notifyDatasetChanged() }, itemClick = { handleConversationClick(it) } ) conversations_list.adapter = currAdapter if (areSystemAnimationsEnabled) { conversations_list.scheduleLayoutAnimation() } } return currAdapter as ConversationsAdapter } private fun setupConversations(conversations: ArrayList, cached: Boolean = false) { val sortedConversations = conversations.sortedWith( compareByDescending { config.pinnedConversations.contains(it.threadId.toString()) } .thenByDescending { it.date } ).toMutableList() as ArrayList if (cached && config.appRunCount == 1) { // there are no cached conversations on the first run so we show the loading placeholder and progress until we are done loading from telephony showOrHideProgress(conversations.isEmpty()) } else { showOrHideProgress(false) showOrHidePlaceholder(conversations.isEmpty()) } try { getOrCreateConversationsAdapter().apply { updateConversations(sortedConversations) { if (!cached) { showOrHidePlaceholder(currentList.isEmpty()) } } } } catch (ignored: Exception) { } } private fun showOrHideProgress(show: Boolean) { if (show) { conversations_progress_bar.show() no_conversations_placeholder.beVisible() no_conversations_placeholder.text = getString(R.string.loading_messages) } else { conversations_progress_bar.hide() no_conversations_placeholder.beGone() } } private fun showOrHidePlaceholder(show: Boolean) { conversations_fastscroller.beGoneIf(show) no_conversations_placeholder.beVisibleIf(show) no_conversations_placeholder.text = getString(R.string.no_conversations_found) no_conversations_placeholder_2.beVisibleIf(show) } private fun fadeOutSearch() { search_holder.animate().alpha(0f).setDuration(SHORT_ANIMATION_DURATION).withEndAction { search_holder.beGone() searchTextChanged("", true) }.start() } @SuppressLint("NotifyDataSetChanged") private fun notifyDatasetChanged() { getOrCreateConversationsAdapter().notifyDataSetChanged() } private fun handleConversationClick(any: Any) { Intent(this, ThreadActivity::class.java).apply { val conversation = any as Conversation putExtra(THREAD_ID, conversation.threadId) putExtra(THREAD_TITLE, conversation.title) putExtra(WAS_PROTECTION_HANDLED, wasProtectionHandled) startActivity(this) } } private fun launchNewConversation() { hideKeyboard() Intent(this, NewConversationActivity::class.java).apply { startActivity(this) } } @SuppressLint("NewApi") private fun checkShortcut() { val appIconColor = config.appIconColor if (isNougatMR1Plus() && config.lastHandledShortcutColor != appIconColor) { val newConversation = getCreateNewContactShortcut(appIconColor) val manager = getSystemService(ShortcutManager::class.java) try { manager.dynamicShortcuts = listOf(newConversation) config.lastHandledShortcutColor = appIconColor } catch (ignored: Exception) { } } } @SuppressLint("NewApi") private fun getCreateNewContactShortcut(appIconColor: Int): ShortcutInfo { val newEvent = getString(R.string.new_conversation) val drawable = resources.getDrawable(R.drawable.shortcut_plus) (drawable as LayerDrawable).findDrawableByLayerId(R.id.shortcut_plus_background).applyColorFilter(appIconColor) val bmp = drawable.convertToBitmap() val intent = Intent(this, NewConversationActivity::class.java) intent.action = Intent.ACTION_VIEW return ShortcutInfo.Builder(this, "new_conversation") .setShortLabel(newEvent) .setLongLabel(newEvent) .setIcon(Icon.createWithBitmap(bmp)) .setIntent(intent) .build() } private fun searchTextChanged(text: String, forceUpdate: Boolean = false) { if (!main_menu.isSearchOpen && !forceUpdate) { return } lastSearchedText = text search_placeholder_2.beGoneIf(text.length >= 2) if (text.length >= 2) { ensureBackgroundThread { val searchQuery = "%$text%" val messages = messagesDB.getMessagesWithText(searchQuery) val conversations = conversationsDB.getConversationsWithText(searchQuery) if (text == lastSearchedText) { showSearchResults(messages, conversations, text) } } } else { search_placeholder.beVisible() search_results_list.beGone() } } private fun showSearchResults(messages: List, conversations: List, searchedText: String) { val searchResults = ArrayList() conversations.forEach { conversation -> val date = conversation.date.formatDateOrTime(this, true, true) val searchResult = SearchResult(-1, conversation.title, conversation.phoneNumber, date, conversation.threadId, conversation.photoUri) searchResults.add(searchResult) } messages.sortedByDescending { it.id }.forEach { message -> var recipient = message.senderName if (recipient.isEmpty() && message.participants.isNotEmpty()) { val participantNames = message.participants.map { it.name } recipient = TextUtils.join(", ", participantNames) } val date = message.date.formatDateOrTime(this, true, true) val searchResult = SearchResult(message.id, recipient, message.body, date, message.threadId, message.senderPhotoUri) searchResults.add(searchResult) } runOnUiThread { search_results_list.beVisibleIf(searchResults.isNotEmpty()) search_placeholder.beVisibleIf(searchResults.isEmpty()) val currAdapter = search_results_list.adapter if (currAdapter == null) { SearchResultsAdapter(this, searchResults, search_results_list, searchedText) { hideKeyboard() Intent(this, ThreadActivity::class.java).apply { putExtra(THREAD_ID, (it as SearchResult).threadId) putExtra(THREAD_TITLE, it.title) putExtra(SEARCHED_MESSAGE_ID, it.messageId) startActivity(this) } }.apply { search_results_list.adapter = this } } else { (currAdapter as SearchResultsAdapter).updateItems(searchResults, searchedText) } } } private fun launchRecycleBin() { hideKeyboard() startActivity(Intent(applicationContext, RecycleBinConversationsActivity::class.java)) } private fun launchSettings() { hideKeyboard() startActivity(Intent(applicationContext, SettingsActivity::class.java)) } private fun launchAbout() { val licenses = LICENSE_EVENT_BUS or LICENSE_SMS_MMS or LICENSE_INDICATOR_FAST_SCROLL val faqItems = arrayListOf( FAQItem(R.string.faq_2_title, R.string.faq_2_text), FAQItem(R.string.faq_3_title, R.string.faq_3_text), FAQItem(R.string.faq_9_title_commons, R.string.faq_9_text_commons) ) if (!resources.getBoolean(R.bool.hide_google_relations)) { faqItems.add(FAQItem(R.string.faq_2_title_commons, R.string.faq_2_text_commons)) faqItems.add(FAQItem(R.string.faq_6_title_commons, R.string.faq_6_text_commons)) } startAboutActivity(R.string.app_name, licenses, BuildConfig.VERSION_NAME, faqItems, true) } private fun tryToExportMessages() { if (isQPlus()) { ExportMessagesDialog(this, config.lastExportPath, true) { file -> Intent(Intent.ACTION_CREATE_DOCUMENT).apply { type = JSON_MIME_TYPE putExtra(Intent.EXTRA_TITLE, file.name) addCategory(Intent.CATEGORY_OPENABLE) try { startActivityForResult(this, PICK_EXPORT_FILE_INTENT) } catch (e: ActivityNotFoundException) { toast(R.string.system_service_disabled, Toast.LENGTH_LONG) } catch (e: Exception) { showErrorToast(e) } } } } else { handlePermission(PERMISSION_WRITE_STORAGE) { if (it) { ExportMessagesDialog(this, config.lastExportPath, false) { file -> getFileOutputStream(file.toFileDirItem(this), true) { outStream -> exportMessagesTo(outStream) } } } } } } private fun exportMessagesTo(outputStream: OutputStream?) { toast(R.string.exporting) ensureBackgroundThread { smsExporter.exportMessages(outputStream) { val toastId = when (it) { MessagesExporter.ExportResult.EXPORT_OK -> R.string.exporting_successful else -> R.string.exporting_failed } toast(toastId) } } } private fun tryImportMessages() { if (isQPlus()) { Intent(Intent.ACTION_GET_CONTENT).apply { addCategory(Intent.CATEGORY_OPENABLE) type = JSON_MIME_TYPE putExtra(Intent.EXTRA_MIME_TYPES, arrayOf(JSON_MIME_TYPE, XML_MIME_TYPE, TXT_MIME_TYPE)) try { startActivityForResult(this, PICK_IMPORT_SOURCE_INTENT) } catch (e: ActivityNotFoundException) { toast(R.string.system_service_disabled, Toast.LENGTH_LONG) } catch (e: Exception) { showErrorToast(e) } } } else { handlePermission(PERMISSION_READ_STORAGE) { if (it) { importMessages() } } } } private fun importMessages() { FilePickerDialog(this) { showImportMessagesDialog(it) } } private fun showImportMessagesDialog(path: String) { ImportMessagesDialog(this, path) } private fun tryImportMessagesFromFile(uri: Uri) { when (uri.scheme) { "file" -> showImportMessagesDialog(uri.path!!) "content" -> { var tempFile = getTempFile("messages", "backup.json") if (tempFile == null) { toast(R.string.unknown_error_occurred) return } try { val inputStream = contentResolver.openInputStream(uri) val out = FileOutputStream(tempFile) inputStream!!.copyTo(out) // Check is XML and properly rename tempFile.bufferedReader().use { if (it.readLine().startsWith(" toast(R.string.invalid_file_format) } } @Subscribe(threadMode = ThreadMode.MAIN) fun refreshMessages(event: Events.RefreshMessages) { initMessenger() } private fun checkWhatsNewDialog() { arrayListOf().apply { add(Release(48, R.string.release_48)) add(Release(62, R.string.release_62)) checkWhatsNew(this, BuildConfig.VERSION_CODE) } } }