Rename package to org.fossify.messages

This commit is contained in:
Naveen 2024-01-18 01:05:03 +05:30
parent d71db351ca
commit e2f83f49da
No known key found for this signature in database
GPG key ID: 0E155DAD31671DA3
106 changed files with 417 additions and 418 deletions

View file

@ -0,0 +1,11 @@
package org.fossify.messages
import android.app.Application
import org.fossify.commons.extensions.checkUseEnglish
class App : Application() {
override fun onCreate() {
super.onCreate()
checkUseEnglish()
}
}

View file

@ -0,0 +1,177 @@
package org.fossify.messages.activities
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import org.fossify.commons.dialogs.ConfirmationDialog
import org.fossify.commons.extensions.*
import org.fossify.commons.helpers.NavigationIcon
import org.fossify.commons.helpers.WAS_PROTECTION_HANDLED
import org.fossify.commons.helpers.ensureBackgroundThread
import org.fossify.messages.R
import org.fossify.messages.adapters.ArchivedConversationsAdapter
import org.fossify.messages.databinding.ActivityArchivedConversationsBinding
import org.fossify.messages.extensions.config
import org.fossify.messages.extensions.conversationsDB
import org.fossify.messages.extensions.removeAllArchivedConversations
import org.fossify.messages.helpers.THREAD_ID
import org.fossify.messages.helpers.THREAD_TITLE
import org.fossify.messages.models.Conversation
import org.fossify.messages.models.Events
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
class ArchivedConversationsActivity : SimpleActivity() {
private var bus: EventBus? = null
private val binding by viewBinding(ActivityArchivedConversationsBinding::inflate)
@SuppressLint("InlinedApi")
override fun onCreate(savedInstanceState: Bundle?) {
isMaterialActivity = true
super.onCreate(savedInstanceState)
setContentView(binding.root)
setupOptionsMenu()
updateMaterialActivityViews(
mainCoordinatorLayout = binding.archiveCoordinator,
nestedView = binding.conversationsList,
useTransparentNavigation = true,
useTopSearchMenu = false
)
setupMaterialScrollListener(scrollingView = binding.conversationsList, toolbar = binding.archiveToolbar)
loadArchivedConversations()
}
override fun onResume() {
super.onResume()
setupToolbar(binding.archiveToolbar, NavigationIcon.Arrow)
updateMenuColors()
loadArchivedConversations()
}
override fun onDestroy() {
super.onDestroy()
bus?.unregister(this)
}
private fun setupOptionsMenu() {
binding.archiveToolbar.inflateMenu(R.menu.archive_menu)
binding.archiveToolbar.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.empty_archive -> removeAll()
else -> return@setOnMenuItemClickListener false
}
return@setOnMenuItemClickListener true
}
}
private fun updateOptionsMenu(conversations: ArrayList<Conversation>) {
binding.archiveToolbar.menu.apply {
findItem(R.id.empty_archive).isVisible = conversations.isNotEmpty()
}
}
private fun updateMenuColors() {
updateStatusbarColor(getProperBackgroundColor())
}
private fun loadArchivedConversations() {
ensureBackgroundThread {
val conversations = try {
conversationsDB.getAllArchived().toMutableList() as ArrayList<Conversation>
} catch (e: Exception) {
ArrayList()
}
runOnUiThread {
setupConversations(conversations)
}
}
bus = EventBus.getDefault()
try {
bus!!.register(this)
} catch (ignored: Exception) {
}
}
private fun removeAll() {
ConfirmationDialog(
activity = this,
message = "",
messageId = R.string.empty_archive_confirmation,
positive = org.fossify.commons.R.string.yes,
negative = org.fossify.commons.R.string.no
) {
removeAllArchivedConversations {
loadArchivedConversations()
}
}
}
private fun getOrCreateConversationsAdapter(): ArchivedConversationsAdapter {
var currAdapter = binding.conversationsList.adapter
if (currAdapter == null) {
hideKeyboard()
currAdapter = ArchivedConversationsAdapter(
activity = this,
recyclerView = binding.conversationsList,
onRefresh = { notifyDatasetChanged() },
itemClick = { handleConversationClick(it) }
)
binding.conversationsList.adapter = currAdapter
if (areSystemAnimationsEnabled) {
binding.conversationsList.scheduleLayoutAnimation()
}
}
return currAdapter as ArchivedConversationsAdapter
}
private fun setupConversations(conversations: ArrayList<Conversation>) {
val sortedConversations = conversations.sortedWith(
compareByDescending<Conversation> { config.pinnedConversations.contains(it.threadId.toString()) }
.thenByDescending { it.date }
).toMutableList() as ArrayList<Conversation>
showOrHidePlaceholder(conversations.isEmpty())
updateOptionsMenu(conversations)
try {
getOrCreateConversationsAdapter().apply {
updateConversations(sortedConversations)
}
} catch (ignored: Exception) {
}
}
private fun showOrHidePlaceholder(show: Boolean) {
binding.conversationsFastscroller.beGoneIf(show)
binding.noConversationsPlaceholder.beVisibleIf(show)
binding.noConversationsPlaceholder.setTextColor(getProperTextColor())
binding.noConversationsPlaceholder.text = getString(R.string.no_archived_conversations)
}
@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, true)
startActivity(this)
}
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun refreshMessages(event: Events.RefreshMessages) {
loadArchivedConversations()
}
}

View file

@ -0,0 +1,161 @@
package org.fossify.messages.activities
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Intent
import android.media.AudioAttributes
import android.media.AudioManager
import android.media.RingtoneManager
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import androidx.annotation.RequiresApi
import androidx.core.content.res.ResourcesCompat
import org.fossify.commons.extensions.*
import org.fossify.commons.helpers.NavigationIcon
import org.fossify.commons.helpers.ensureBackgroundThread
import org.fossify.commons.helpers.isOreoPlus
import org.fossify.commons.models.SimpleContact
import org.fossify.messages.adapters.ContactsAdapter
import org.fossify.messages.databinding.ActivityConversationDetailsBinding
import org.fossify.messages.dialogs.RenameConversationDialog
import org.fossify.messages.extensions.*
import org.fossify.messages.helpers.THREAD_ID
import org.fossify.messages.models.Conversation
class ConversationDetailsActivity : SimpleActivity() {
private var threadId: Long = 0L
private var conversation: Conversation? = null
private lateinit var participants: ArrayList<SimpleContact>
private val binding by viewBinding(ActivityConversationDetailsBinding::inflate)
override fun onCreate(savedInstanceState: Bundle?) {
isMaterialActivity = true
super.onCreate(savedInstanceState)
setContentView(binding.root)
updateMaterialActivityViews(
mainCoordinatorLayout = binding.conversationDetailsCoordinator,
nestedView = binding.participantsRecyclerview,
useTransparentNavigation = true,
useTopSearchMenu = false
)
setupMaterialScrollListener(scrollingView = binding.participantsRecyclerview, toolbar = binding.conversationDetailsToolbar)
threadId = intent.getLongExtra(THREAD_ID, 0L)
ensureBackgroundThread {
conversation = conversationsDB.getConversationWithThreadId(threadId)
participants = if (conversation != null && conversation!!.isScheduled) {
val message = messagesDB.getThreadMessages(conversation!!.threadId).firstOrNull()
message?.participants ?: arrayListOf()
} else {
getThreadParticipants(threadId, null)
}
runOnUiThread {
setupTextViews()
setupParticipants()
if (isOreoPlus()) {
setupCustomNotifications()
}
}
}
}
override fun onResume() {
super.onResume()
setupToolbar(binding.conversationDetailsToolbar, NavigationIcon.Arrow)
updateTextColors(binding.conversationDetailsHolder)
val primaryColor = getProperPrimaryColor()
binding.conversationNameHeading.setTextColor(primaryColor)
binding.membersHeading.setTextColor(primaryColor)
}
@RequiresApi(Build.VERSION_CODES.O)
private fun setupCustomNotifications() {
binding.apply {
notificationsHeading.beVisible()
customNotificationsHolder.beVisible()
customNotifications.isChecked = config.customNotifications.contains(threadId.toString())
customNotificationsButton.beVisibleIf(customNotifications.isChecked)
customNotificationsHolder.setOnClickListener {
customNotifications.toggle()
if (customNotifications.isChecked) {
customNotificationsButton.beVisible()
config.addCustomNotificationsByThreadId(threadId)
createNotificationChannel()
} else {
customNotificationsButton.beGone()
config.removeCustomNotificationsByThreadId(threadId)
removeNotificationChannel()
}
}
customNotificationsButton.setOnClickListener {
Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
putExtra(Settings.EXTRA_CHANNEL_ID, threadId.toString())
startActivity(this)
}
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
private fun createNotificationChannel() {
val name = conversation?.title
val audioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setLegacyStreamType(AudioManager.STREAM_NOTIFICATION)
.build()
NotificationChannel(threadId.toString(), name, NotificationManager.IMPORTANCE_HIGH).apply {
setBypassDnd(false)
enableLights(true)
setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION), audioAttributes)
enableVibration(true)
notificationManager.createNotificationChannel(this)
}
}
@RequiresApi(Build.VERSION_CODES.O)
private fun removeNotificationChannel() {
notificationManager.deleteNotificationChannel(threadId.toString())
}
private fun setupTextViews() {
binding.conversationName.apply {
ResourcesCompat.getDrawable(resources, org.fossify.commons.R.drawable.ic_edit_vector, theme)?.apply {
applyColorFilter(getProperTextColor())
setCompoundDrawablesWithIntrinsicBounds(null, null, this, null)
}
text = conversation?.title
setOnClickListener {
RenameConversationDialog(this@ConversationDetailsActivity, conversation!!) { title ->
text = title
ensureBackgroundThread {
conversation = renameConversation(conversation!!, newTitle = title)
}
}
}
}
}
private fun setupParticipants() {
val adapter = ContactsAdapter(this, participants, binding.participantsRecyclerview) {
val contact = it as SimpleContact
val address = contact.phoneNumbers.first().normalizedNumber
getContactFromAddress(address) { simpleContact ->
if (simpleContact != null) {
startContactDetailsIntent(simpleContact)
}
}
}
binding.participantsRecyclerview.adapter = adapter
}
}

View file

@ -0,0 +1,607 @@
package org.fossify.messages.activities
import android.annotation.SuppressLint
import android.app.Activity
import android.app.role.RoleManager
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.os.Bundle
import android.provider.Telephony
import android.text.TextUtils
import androidx.coordinatorlayout.widget.CoordinatorLayout
import org.fossify.commons.dialogs.PermissionRequiredDialog
import org.fossify.commons.extensions.*
import org.fossify.commons.helpers.*
import org.fossify.commons.models.FAQItem
import org.fossify.commons.models.Release
import org.fossify.messages.BuildConfig
import org.fossify.messages.R
import org.fossify.messages.adapters.ConversationsAdapter
import org.fossify.messages.adapters.SearchResultsAdapter
import org.fossify.messages.databinding.ActivityMainBinding
import org.fossify.messages.extensions.*
import org.fossify.messages.helpers.SEARCHED_MESSAGE_ID
import org.fossify.messages.helpers.THREAD_ID
import org.fossify.messages.helpers.THREAD_TITLE
import org.fossify.messages.models.Conversation
import org.fossify.messages.models.Events
import org.fossify.messages.models.Message
import org.fossify.messages.models.SearchResult
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
class MainActivity : SimpleActivity() {
private val MAKE_DEFAULT_APP_REQUEST = 1
private var storedTextColor = 0
private var storedFontSize = 0
private var lastSearchedText = ""
private var bus: EventBus? = null
private var wasProtectionHandled = false
private val binding by viewBinding(ActivityMainBinding::inflate)
@SuppressLint("InlinedApi")
override fun onCreate(savedInstanceState: Bundle?) {
isMaterialActivity = true
super.onCreate(savedInstanceState)
setContentView(binding.root)
appLaunched(BuildConfig.APPLICATION_ID)
setupOptionsMenu()
refreshMenuItems()
updateMaterialActivityViews(
mainCoordinatorLayout = binding.mainCoordinator,
nestedView = binding.conversationsList,
useTransparentNavigation = true,
useTopSearchMenu = true
)
if (savedInstanceState == null) {
checkAndDeleteOldRecycleBinMessages()
handleAppPasswordProtection {
wasProtectionHandled = it
if (it) {
clearAllMessagesIfNeeded {
loadMessages()
}
} else {
finish()
}
}
}
if (checkAppSideloading()) {
return
}
}
override fun onResume() {
super.onResume()
updateMenuColors()
refreshMenuItems()
getOrCreateConversationsAdapter().apply {
if (storedTextColor != getProperTextColor()) {
updateTextColor(getProperTextColor())
}
if (storedFontSize != config.fontSize) {
updateFontSize()
}
updateDrafts()
}
updateTextColors(binding.mainCoordinator)
binding.searchHolder.setBackgroundColor(getProperBackgroundColor())
val properPrimaryColor = getProperPrimaryColor()
binding.noConversationsPlaceholder2.setTextColor(properPrimaryColor)
binding.noConversationsPlaceholder2.underlineText()
binding.conversationsFastscroller.updateColors(properPrimaryColor)
binding.conversationsProgressBar.setIndicatorColor(properPrimaryColor)
binding.conversationsProgressBar.trackColor = properPrimaryColor.adjustAlpha(LOWER_ALPHA)
checkShortcut()
(binding.conversationsFab.layoutParams as? CoordinatorLayout.LayoutParams)?.bottomMargin =
navigationBarHeight + resources.getDimension(org.fossify.commons.R.dimen.activity_margin).toInt()
}
override fun onPause() {
super.onPause()
storeStateVariables()
}
override fun onDestroy() {
super.onDestroy()
bus?.unregister(this)
}
override fun onBackPressed() {
if (binding.mainMenu.isSearchOpen) {
binding.mainMenu.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() {
binding.mainMenu.getToolbar().inflateMenu(R.menu.menu_main)
binding.mainMenu.toggleHideOnScroll(true)
binding.mainMenu.setupMenu()
binding.mainMenu.onSearchClosedListener = {
fadeOutSearch()
}
binding.mainMenu.onSearchTextChangedListener = { text ->
if (text.isNotEmpty()) {
if (binding.searchHolder.alpha < 1f) {
binding.searchHolder.fadeIn()
}
} else {
fadeOutSearch()
}
searchTextChanged(text)
}
binding.mainMenu.getToolbar().setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.more_apps_from_us -> launchMoreAppsFromUsIntent()
R.id.show_recycle_bin -> launchRecycleBin()
R.id.show_archived -> launchArchivedConversations()
R.id.settings -> launchSettings()
R.id.about -> launchAbout()
else -> return@setOnMenuItemClickListener false
}
return@setOnMenuItemClickListener true
}
}
private fun refreshMenuItems() {
binding.mainMenu.getToolbar().menu.apply {
findItem(R.id.more_apps_from_us).isVisible = !resources.getBoolean(org.fossify.commons.R.bool.hide_google_relations)
findItem(R.id.show_recycle_bin).isVisible = config.useRecycleBin
findItem(R.id.show_archived).isVisible = config.isArchiveAvailable
}
}
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()
}
}
}
private fun storeStateVariables() {
storedTextColor = getProperTextColor()
storedFontSize = config.fontSize
}
private fun updateMenuColors() {
updateStatusbarColor(getProperBackgroundColor())
binding.mainMenu.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(org.fossify.commons.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(
activity = this,
textId = org.fossify.commons.R.string.allow_notifications_incoming_messages,
positiveActionCallback = { openNotificationSettings() })
}
}
initMessenger()
bus = EventBus.getDefault()
try {
bus!!.register(this)
} catch (ignored: Exception) {
}
}
} else {
finish()
}
}
} else {
finish()
}
}
}
private fun initMessenger() {
checkWhatsNewDialog()
storeStateVariables()
getCachedConversations()
binding.noConversationsPlaceholder2.setOnClickListener {
launchNewConversation()
}
binding.conversationsFab.setOnClickListener {
launchNewConversation()
}
}
private fun getCachedConversations() {
ensureBackgroundThread {
val conversations = try {
conversationsDB.getNonArchived().toMutableList() as ArrayList<Conversation>
} catch (e: Exception) {
ArrayList()
}
val archived = try {
conversationsDB.getAllArchived()
} catch (e: Exception) {
listOf()
}
updateUnreadCountBadge(conversations)
runOnUiThread {
setupConversations(conversations, cached = true)
getNewConversations((conversations + archived).toMutableList() as ArrayList<Conversation>)
}
conversations.forEach {
clearExpiredScheduledMessages(it.threadId)
}
}
}
private fun getNewConversations(cachedConversations: ArrayList<Conversation>) {
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.getNonArchived() as ArrayList<Conversation>
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 = binding.conversationsList.adapter
if (currAdapter == null) {
hideKeyboard()
currAdapter = ConversationsAdapter(
activity = this,
recyclerView = binding.conversationsList,
onRefresh = { notifyDatasetChanged() },
itemClick = { handleConversationClick(it) }
)
binding.conversationsList.adapter = currAdapter
if (areSystemAnimationsEnabled) {
binding.conversationsList.scheduleLayoutAnimation()
}
}
return currAdapter as ConversationsAdapter
}
private fun setupConversations(conversations: ArrayList<Conversation>, cached: Boolean = false) {
val sortedConversations = conversations.sortedWith(
compareByDescending<Conversation> { config.pinnedConversations.contains(it.threadId.toString()) }
.thenByDescending { it.date }
).toMutableList() as ArrayList<Conversation>
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) {
binding.conversationsProgressBar.show()
binding.noConversationsPlaceholder.beVisible()
binding.noConversationsPlaceholder.text = getString(R.string.loading_messages)
} else {
binding.conversationsProgressBar.hide()
binding.noConversationsPlaceholder.beGone()
}
}
private fun showOrHidePlaceholder(show: Boolean) {
binding.conversationsFastscroller.beGoneIf(show)
binding.noConversationsPlaceholder.beVisibleIf(show)
binding.noConversationsPlaceholder.text = getString(R.string.no_conversations_found)
binding.noConversationsPlaceholder2.beVisibleIf(show)
}
private fun fadeOutSearch() {
binding.searchHolder.animate().alpha(0f).setDuration(SHORT_ANIMATION_DURATION).withEndAction {
binding.searchHolder.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(org.fossify.commons.R.drawable.shortcut_plus)
(drawable as LayerDrawable).findDrawableByLayerId(org.fossify.commons.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 (!binding.mainMenu.isSearchOpen && !forceUpdate) {
return
}
lastSearchedText = text
binding.searchPlaceholder2.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 {
binding.searchPlaceholder.beVisible()
binding.searchResultsList.beGone()
}
}
private fun showSearchResults(messages: List<Message>, conversations: List<Conversation>, searchedText: String) {
val searchResults = ArrayList<SearchResult>()
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 {
binding.searchResultsList.beVisibleIf(searchResults.isNotEmpty())
binding.searchPlaceholder.beVisibleIf(searchResults.isEmpty())
val currAdapter = binding.searchResultsList.adapter
if (currAdapter == null) {
SearchResultsAdapter(this, searchResults, binding.searchResultsList, 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 {
binding.searchResultsList.adapter = this
}
} else {
(currAdapter as SearchResultsAdapter).updateItems(searchResults, searchedText)
}
}
}
private fun launchRecycleBin() {
hideKeyboard()
startActivity(Intent(applicationContext, RecycleBinConversationsActivity::class.java))
}
private fun launchArchivedConversations() {
hideKeyboard()
startActivity(Intent(applicationContext, ArchivedConversationsActivity::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(org.fossify.commons.R.string.faq_9_title_commons, org.fossify.commons.R.string.faq_9_text_commons)
)
if (!resources.getBoolean(org.fossify.commons.R.bool.hide_google_relations)) {
faqItems.add(FAQItem(org.fossify.commons.R.string.faq_2_title_commons, org.fossify.commons.R.string.faq_2_text_commons))
faqItems.add(FAQItem(org.fossify.commons.R.string.faq_6_title_commons, org.fossify.commons.R.string.faq_6_text_commons))
}
startAboutActivity(R.string.app_name, licenses, BuildConfig.VERSION_NAME, faqItems, true)
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun refreshMessages(event: Events.RefreshMessages) {
initMessenger()
}
private fun checkWhatsNewDialog() {
arrayListOf<Release>().apply {
checkWhatsNew(this, BuildConfig.VERSION_CODE)
}
}
}

View file

@ -0,0 +1,93 @@
package org.fossify.messages.activities
import android.os.Bundle
import org.fossify.commons.activities.BaseSimpleActivity
import org.fossify.commons.extensions.*
import org.fossify.commons.helpers.APP_ICON_IDS
import org.fossify.commons.helpers.APP_LAUNCHER_NAME
import org.fossify.commons.helpers.NavigationIcon
import org.fossify.commons.helpers.ensureBackgroundThread
import org.fossify.commons.interfaces.RefreshRecyclerViewListener
import org.fossify.messages.R
import org.fossify.messages.databinding.ActivityManageBlockedKeywordsBinding
import org.fossify.messages.dialogs.AddBlockedKeywordDialog
import org.fossify.messages.dialogs.ManageBlockedKeywordsAdapter
import org.fossify.messages.extensions.config
import org.fossify.messages.extensions.toArrayList
class ManageBlockedKeywordsActivity : BaseSimpleActivity(), RefreshRecyclerViewListener {
override fun getAppIconIDs() = intent.getIntegerArrayListExtra(APP_ICON_IDS) ?: ArrayList()
override fun getAppLauncherName() = intent.getStringExtra(APP_LAUNCHER_NAME) ?: ""
private val binding by viewBinding(ActivityManageBlockedKeywordsBinding::inflate)
override fun onCreate(savedInstanceState: Bundle?) {
isMaterialActivity = true
super.onCreate(savedInstanceState)
setContentView(binding.root)
updateBlockedKeywords()
setupOptionsMenu()
updateMaterialActivityViews(
mainCoordinatorLayout = binding.blockKeywordsCoordinator,
nestedView = binding.manageBlockedKeywordsList,
useTransparentNavigation = true,
useTopSearchMenu = false
)
setupMaterialScrollListener(scrollingView = binding.manageBlockedKeywordsList, toolbar = binding.blockKeywordsToolbar)
updateTextColors(binding.manageBlockedKeywordsWrapper)
binding.manageBlockedKeywordsPlaceholder2.apply {
underlineText()
setTextColor(getProperPrimaryColor())
setOnClickListener {
addOrEditBlockedKeyword()
}
}
}
override fun onResume() {
super.onResume()
setupToolbar(binding.blockKeywordsToolbar, NavigationIcon.Arrow)
}
private fun setupOptionsMenu() {
binding.blockKeywordsToolbar.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.add_blocked_keyword -> {
addOrEditBlockedKeyword()
true
}
else -> false
}
}
}
override fun refreshItems() {
updateBlockedKeywords()
}
private fun updateBlockedKeywords() {
ensureBackgroundThread {
val blockedKeywords = config.blockedKeywords
runOnUiThread {
ManageBlockedKeywordsAdapter(this, blockedKeywords.toArrayList(), this, binding.manageBlockedKeywordsList) {
addOrEditBlockedKeyword(it as String)
}.apply {
binding.manageBlockedKeywordsList.adapter = this
}
binding.manageBlockedKeywordsPlaceholder.beVisibleIf(blockedKeywords.isEmpty())
binding.manageBlockedKeywordsPlaceholder2.beVisibleIf(blockedKeywords.isEmpty())
}
}
}
private fun addOrEditBlockedKeyword(keyword: String? = null) {
AddBlockedKeywordDialog(this, keyword) {
updateBlockedKeywords()
}
}
}

View file

@ -0,0 +1,263 @@
package org.fossify.messages.activities
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.WindowManager
import android.widget.Toast
import com.google.gson.Gson
import com.reddit.indicatorfastscroll.FastScrollItemIndicator
import org.fossify.commons.dialogs.RadioGroupDialog
import org.fossify.commons.extensions.*
import org.fossify.commons.helpers.*
import org.fossify.commons.models.RadioItem
import org.fossify.commons.models.SimpleContact
import org.fossify.messages.R
import org.fossify.messages.adapters.ContactsAdapter
import org.fossify.messages.databinding.ActivityNewConversationBinding
import org.fossify.messages.databinding.ItemSuggestedContactBinding
import org.fossify.messages.extensions.getSuggestedContacts
import org.fossify.messages.extensions.getThreadId
import org.fossify.messages.helpers.*
import org.fossify.messages.messaging.isShortCodeWithLetters
import java.net.URLDecoder
import java.util.Locale
class NewConversationActivity : SimpleActivity() {
private var allContacts = ArrayList<SimpleContact>()
private var privateContacts = ArrayList<SimpleContact>()
private val binding by viewBinding(ActivityNewConversationBinding::inflate)
override fun onCreate(savedInstanceState: Bundle?) {
isMaterialActivity = true
super.onCreate(savedInstanceState)
setContentView(binding.root)
title = getString(R.string.new_conversation)
updateTextColors(binding.newConversationHolder)
updateMaterialActivityViews(
mainCoordinatorLayout = binding.newConversationCoordinator,
nestedView = binding.contactsList,
useTransparentNavigation = true,
useTopSearchMenu = false
)
setupMaterialScrollListener(scrollingView = binding.contactsList, toolbar = binding.newConversationToolbar)
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
binding.newConversationAddress.requestFocus()
// READ_CONTACTS permission is not mandatory, but without it we won't be able to show any suggestions during typing
handlePermission(PERMISSION_READ_CONTACTS) {
initContacts()
}
}
override fun onResume() {
super.onResume()
setupToolbar(binding.newConversationToolbar, NavigationIcon.Arrow)
binding.noContactsPlaceholder2.setTextColor(getProperPrimaryColor())
binding.noContactsPlaceholder2.underlineText()
binding.suggestionsLabel.setTextColor(getProperPrimaryColor())
}
private fun initContacts() {
if (isThirdPartyIntent()) {
return
}
fetchContacts()
binding.newConversationAddress.onTextChangeListener { searchString ->
val filteredContacts = ArrayList<SimpleContact>()
allContacts.forEach { contact ->
if (contact.phoneNumbers.any { it.normalizedNumber.contains(searchString, true) } ||
contact.name.contains(searchString, true) ||
contact.name.contains(searchString.normalizeString(), true) ||
contact.name.normalizeString().contains(searchString, true)) {
filteredContacts.add(contact)
}
}
filteredContacts.sortWith(compareBy { !it.name.startsWith(searchString, true) })
setupAdapter(filteredContacts)
binding.newConversationConfirm.beVisibleIf(searchString.length > 2)
}
binding.newConversationConfirm.applyColorFilter(getProperTextColor())
binding.newConversationConfirm.setOnClickListener {
val number = binding.newConversationAddress.value
if (isShortCodeWithLetters(number)) {
binding.newConversationAddress.setText("")
toast(R.string.invalid_short_code, length = Toast.LENGTH_LONG)
return@setOnClickListener
}
launchThreadActivity(number, number)
}
binding.noContactsPlaceholder2.setOnClickListener {
handlePermission(PERMISSION_READ_CONTACTS) {
if (it) {
fetchContacts()
}
}
}
val properPrimaryColor = getProperPrimaryColor()
binding.contactsLetterFastscroller.textColor = getProperTextColor().getColorStateList()
binding.contactsLetterFastscroller.pressedTextColor = properPrimaryColor
binding.contactsLetterFastscrollerThumb.setupWithFastScroller(binding.contactsLetterFastscroller)
binding.contactsLetterFastscrollerThumb.textColor = properPrimaryColor.getContrastColor()
binding.contactsLetterFastscrollerThumb.thumbColor = properPrimaryColor.getColorStateList()
}
private fun isThirdPartyIntent(): Boolean {
if ((intent.action == Intent.ACTION_SENDTO || intent.action == Intent.ACTION_SEND || intent.action == Intent.ACTION_VIEW) && intent.dataString != null) {
val number = intent.dataString!!.removePrefix("sms:").removePrefix("smsto:").removePrefix("mms").removePrefix("mmsto:").replace("+", "%2b").trim()
launchThreadActivity(URLDecoder.decode(number), "")
finish()
return true
}
return false
}
private fun fetchContacts() {
fillSuggestedContacts {
SimpleContactsHelper(this).getAvailableContacts(false) {
allContacts = it
if (privateContacts.isNotEmpty()) {
allContacts.addAll(privateContacts)
allContacts.sort()
}
runOnUiThread {
setupAdapter(allContacts)
}
}
}
}
private fun setupAdapter(contacts: ArrayList<SimpleContact>) {
val hasContacts = contacts.isNotEmpty()
binding.contactsList.beVisibleIf(hasContacts)
binding.noContactsPlaceholder.beVisibleIf(!hasContacts)
binding.noContactsPlaceholder2.beVisibleIf(!hasContacts && !hasPermission(PERMISSION_READ_CONTACTS))
if (!hasContacts) {
val placeholderText = if (hasPermission(PERMISSION_READ_CONTACTS)) {
org.fossify.commons.R.string.no_contacts_found
} else {
org.fossify.commons.R.string.no_access_to_contacts
}
binding.noContactsPlaceholder.text = getString(placeholderText)
}
val currAdapter = binding.contactsList.adapter
if (currAdapter == null) {
ContactsAdapter(this, contacts, binding.contactsList) {
hideKeyboard()
val contact = it as SimpleContact
val phoneNumbers = contact.phoneNumbers
if (phoneNumbers.size > 1) {
val primaryNumber = contact.phoneNumbers.find { it.isPrimary }
if (primaryNumber != null) {
launchThreadActivity(primaryNumber.value, contact.name)
} else {
val items = ArrayList<RadioItem>()
phoneNumbers.forEachIndexed { index, phoneNumber ->
val type = getPhoneNumberTypeText(phoneNumber.type, phoneNumber.label)
items.add(RadioItem(index, "${phoneNumber.normalizedNumber} ($type)", phoneNumber.normalizedNumber))
}
RadioGroupDialog(this, items) {
launchThreadActivity(it as String, contact.name)
}
}
} else {
launchThreadActivity(phoneNumbers.first().normalizedNumber, contact.name)
}
}.apply {
binding.contactsList.adapter = this
}
if (areSystemAnimationsEnabled) {
binding.contactsList.scheduleLayoutAnimation()
}
} else {
(currAdapter as ContactsAdapter).updateContacts(contacts)
}
setupLetterFastscroller(contacts)
}
private fun fillSuggestedContacts(callback: () -> Unit) {
val privateCursor = getMyContactsCursor(false, true)
ensureBackgroundThread {
privateContacts = MyContactsContentProvider.getSimpleContacts(this, privateCursor)
val suggestions = getSuggestedContacts(privateContacts)
runOnUiThread {
binding.suggestionsHolder.removeAllViews()
if (suggestions.isEmpty()) {
binding.suggestionsLabel.beGone()
binding.suggestionsScrollview.beGone()
} else {
binding.suggestionsLabel.beVisible()
binding.suggestionsScrollview.beVisible()
suggestions.forEach {
val contact = it
ItemSuggestedContactBinding.inflate(layoutInflater).apply {
suggestedContactName.text = contact.name
suggestedContactName.setTextColor(getProperTextColor())
if (!isDestroyed) {
SimpleContactsHelper(this@NewConversationActivity).loadContactImage(contact.photoUri, suggestedContactImage, contact.name)
binding.suggestionsHolder.addView(root)
root.setOnClickListener {
launchThreadActivity(contact.phoneNumbers.first().normalizedNumber, contact.name)
}
}
}
}
}
callback()
}
}
}
private fun setupLetterFastscroller(contacts: ArrayList<SimpleContact>) {
binding.contactsLetterFastscroller.setupWithRecyclerView(binding.contactsList, { position ->
try {
val name = contacts[position].name
val character = if (name.isNotEmpty()) name.substring(0, 1) else ""
FastScrollItemIndicator.Text(character.uppercase(Locale.getDefault()).normalizeString())
} catch (e: Exception) {
FastScrollItemIndicator.Text("")
}
})
}
private fun launchThreadActivity(phoneNumber: String, name: String) {
hideKeyboard()
val text = intent.getStringExtra(Intent.EXTRA_TEXT) ?: intent.getStringExtra("sms_body") ?: ""
val numbers = phoneNumber.split(";").toSet()
val number = if (numbers.size == 1) phoneNumber else Gson().toJson(numbers)
Intent(this, ThreadActivity::class.java).apply {
putExtra(THREAD_ID, getThreadId(numbers))
putExtra(THREAD_TITLE, name)
putExtra(THREAD_TEXT, text)
putExtra(THREAD_NUMBER, number)
if (intent.action == Intent.ACTION_SEND && intent.extras?.containsKey(Intent.EXTRA_STREAM) == true) {
val uri = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
putExtra(THREAD_ATTACHMENT_URI, uri?.toString())
} else if (intent.action == Intent.ACTION_SEND_MULTIPLE && intent.extras?.containsKey(Intent.EXTRA_STREAM) == true) {
val uris = intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
putExtra(THREAD_ATTACHMENT_URIS, uris)
}
startActivity(this)
}
}
}

View file

@ -0,0 +1,179 @@
package org.fossify.messages.activities
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import org.fossify.commons.dialogs.ConfirmationDialog
import org.fossify.commons.extensions.*
import org.fossify.commons.helpers.NavigationIcon
import org.fossify.commons.helpers.WAS_PROTECTION_HANDLED
import org.fossify.commons.helpers.ensureBackgroundThread
import org.fossify.messages.R
import org.fossify.messages.adapters.RecycleBinConversationsAdapter
import org.fossify.messages.databinding.ActivityRecycleBinConversationsBinding
import org.fossify.messages.extensions.config
import org.fossify.messages.extensions.conversationsDB
import org.fossify.messages.extensions.emptyMessagesRecycleBin
import org.fossify.messages.helpers.IS_RECYCLE_BIN
import org.fossify.messages.helpers.THREAD_ID
import org.fossify.messages.helpers.THREAD_TITLE
import org.fossify.messages.models.Conversation
import org.fossify.messages.models.Events
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
class RecycleBinConversationsActivity : SimpleActivity() {
private var bus: EventBus? = null
private val binding by viewBinding(ActivityRecycleBinConversationsBinding::inflate)
@SuppressLint("InlinedApi")
override fun onCreate(savedInstanceState: Bundle?) {
isMaterialActivity = true
super.onCreate(savedInstanceState)
setContentView(binding.root)
setupOptionsMenu()
updateMaterialActivityViews(
mainCoordinatorLayout = binding.recycleBinCoordinator,
nestedView = binding.conversationsList,
useTransparentNavigation = true,
useTopSearchMenu = false
)
setupMaterialScrollListener(scrollingView = binding.conversationsList, toolbar = binding.recycleBinToolbar)
loadRecycleBinConversations()
}
override fun onResume() {
super.onResume()
setupToolbar(binding.recycleBinToolbar, NavigationIcon.Arrow)
updateMenuColors()
loadRecycleBinConversations()
}
override fun onDestroy() {
super.onDestroy()
bus?.unregister(this)
}
private fun setupOptionsMenu() {
binding.recycleBinToolbar.inflateMenu(R.menu.recycle_bin_menu)
binding.recycleBinToolbar.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.empty_recycle_bin -> removeAll()
else -> return@setOnMenuItemClickListener false
}
return@setOnMenuItemClickListener true
}
}
private fun updateOptionsMenu(conversations: ArrayList<Conversation>) {
binding.recycleBinToolbar.menu.apply {
findItem(R.id.empty_recycle_bin).isVisible = conversations.isNotEmpty()
}
}
private fun updateMenuColors() {
updateStatusbarColor(getProperBackgroundColor())
}
private fun loadRecycleBinConversations() {
ensureBackgroundThread {
val conversations = try {
conversationsDB.getAllWithMessagesInRecycleBin().toMutableList() as ArrayList<Conversation>
} catch (e: Exception) {
ArrayList()
}
runOnUiThread {
setupConversations(conversations)
}
}
bus = EventBus.getDefault()
try {
bus!!.register(this)
} catch (ignored: Exception) {
}
}
private fun removeAll() {
ConfirmationDialog(
activity = this,
message = "",
messageId = R.string.empty_recycle_bin_messages_confirmation,
positive = org.fossify.commons.R.string.yes,
negative = org.fossify.commons.R.string.no
) {
ensureBackgroundThread {
emptyMessagesRecycleBin()
loadRecycleBinConversations()
}
}
}
private fun getOrCreateConversationsAdapter(): RecycleBinConversationsAdapter {
var currAdapter = binding.conversationsList.adapter
if (currAdapter == null) {
hideKeyboard()
currAdapter = RecycleBinConversationsAdapter(
activity = this,
recyclerView = binding.conversationsList,
onRefresh = { notifyDatasetChanged() },
itemClick = { handleConversationClick(it) }
)
binding.conversationsList.adapter = currAdapter
if (areSystemAnimationsEnabled) {
binding.conversationsList.scheduleLayoutAnimation()
}
}
return currAdapter as RecycleBinConversationsAdapter
}
private fun setupConversations(conversations: ArrayList<Conversation>) {
val sortedConversations = conversations.sortedWith(
compareByDescending<Conversation> { config.pinnedConversations.contains(it.threadId.toString()) }
.thenByDescending { it.date }
).toMutableList() as ArrayList<Conversation>
showOrHidePlaceholder(conversations.isEmpty())
updateOptionsMenu(conversations)
try {
getOrCreateConversationsAdapter().apply {
updateConversations(sortedConversations)
}
} catch (ignored: Exception) {
}
}
private fun showOrHidePlaceholder(show: Boolean) {
binding.conversationsFastscroller.beGoneIf(show)
binding.noConversationsPlaceholder.beVisibleIf(show)
binding.noConversationsPlaceholder.text = getString(R.string.no_conversations_found)
}
@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, true)
putExtra(IS_RECYCLE_BIN, true)
startActivity(this)
}
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun refreshMessages(event: Events.RefreshMessages) {
loadRecycleBinConversations()
}
}

View file

@ -0,0 +1,418 @@
package org.fossify.messages.activities
import android.annotation.TargetApi
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import androidx.activity.result.contract.ActivityResultContracts
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.fossify.commons.activities.ManageBlockedNumbersActivity
import org.fossify.commons.dialogs.*
import org.fossify.commons.extensions.*
import org.fossify.commons.helpers.*
import org.fossify.commons.models.RadioItem
import org.fossify.messages.R
import org.fossify.messages.databinding.ActivitySettingsBinding
import org.fossify.messages.dialogs.ExportMessagesDialog
import org.fossify.messages.extensions.config
import org.fossify.messages.extensions.emptyMessagesRecycleBin
import org.fossify.messages.extensions.messagesDB
import org.fossify.messages.helpers.*
import java.util.Locale
import kotlin.system.exitProcess
class SettingsActivity : SimpleActivity() {
private var blockedNumbersAtPause = -1
private var recycleBinMessages = 0
private val messagesFileType = "application/json"
private val messageImportFileTypes = listOf("application/json", "application/xml", "text/xml")
private val binding by viewBinding(ActivitySettingsBinding::inflate)
override fun onCreate(savedInstanceState: Bundle?) {
isMaterialActivity = true
super.onCreate(savedInstanceState)
setContentView(binding.root)
updateMaterialActivityViews(
mainCoordinatorLayout = binding.settingsCoordinator,
nestedView = binding.settingsHolder,
useTransparentNavigation = true,
useTopSearchMenu = false
)
setupMaterialScrollListener(scrollingView = binding.settingsNestedScrollview, toolbar = binding.settingsToolbar)
}
override fun onResume() {
super.onResume()
setupToolbar(binding.settingsToolbar, NavigationIcon.Arrow)
setupPurchaseThankYou()
setupCustomizeColors()
setupCustomizeNotifications()
setupUseEnglish()
setupLanguage()
setupManageBlockedNumbers()
setupManageBlockedKeywords()
setupChangeDateTimeFormat()
setupFontSize()
setupShowCharacterCounter()
setupUseSimpleCharacters()
setupSendOnEnter()
setupEnableDeliveryReports()
setupSendLongMessageAsMMS()
setupGroupMessageAsMMS()
setupLockScreenVisibility()
setupMMSFileSizeLimit()
setupUseRecycleBin()
setupEmptyRecycleBin()
setupAppPasswordProtection()
setupMessagesExport()
setupMessagesImport()
updateTextColors(binding.settingsNestedScrollview)
if (blockedNumbersAtPause != -1 && blockedNumbersAtPause != getBlockedNumbers().hashCode()) {
refreshMessages()
}
arrayOf(
binding.settingsColorCustomizationSectionLabel,
binding.settingsGeneralSettingsLabel,
binding.settingsOutgoingMessagesLabel,
binding.settingsNotificationsLabel,
binding.settingsRecycleBinLabel,
binding.settingsSecurityLabel,
binding.settingsMigratingLabel
).forEach {
it.setTextColor(getProperPrimaryColor())
}
}
private val getContent = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri != null) {
MessagesImporter(this).importMessages(uri)
}
}
private val saveDocument = registerForActivityResult(ActivityResultContracts.CreateDocument(messagesFileType)) { uri ->
if (uri != null) {
toast(org.fossify.commons.R.string.exporting)
exportMessages(uri)
}
}
private fun setupMessagesExport() {
binding.settingsExportMessagesHolder.setOnClickListener {
ExportMessagesDialog(this) { fileName ->
saveDocument.launch(fileName)
}
}
}
private fun setupMessagesImport() {
binding.settingsImportMessagesHolder.setOnClickListener {
getContent.launch(messageImportFileTypes.toTypedArray())
}
}
private fun exportMessages(uri: Uri) {
ensureBackgroundThread {
try {
MessagesReader(this).getMessagesToExport(config.exportSms, config.exportMms) { messagesToExport ->
if (messagesToExport.isEmpty()) {
toast(org.fossify.commons.R.string.no_entries_for_exporting)
return@getMessagesToExport
}
val json = Json { encodeDefaults = true }
val jsonString = json.encodeToString(messagesToExport)
val outputStream = contentResolver.openOutputStream(uri)!!
outputStream.use {
it.write(jsonString.toByteArray())
}
toast(org.fossify.commons.R.string.exporting_successful)
}
} catch (e: Exception) {
showErrorToast(e)
}
}
}
override fun onPause() {
super.onPause()
blockedNumbersAtPause = getBlockedNumbers().hashCode()
}
private fun setupPurchaseThankYou() = binding.apply {
settingsPurchaseThankYouHolder.beGoneIf(isOrWasThankYouInstalled())
settingsPurchaseThankYouHolder.setOnClickListener {
launchPurchaseThankYouIntent()
}
}
private fun setupCustomizeColors() = binding.apply {
settingsColorCustomizationLabel.text = getCustomizeColorsString()
settingsColorCustomizationHolder.setOnClickListener {
handleCustomizeColorsClick()
}
}
private fun setupCustomizeNotifications() = binding.apply {
settingsCustomizeNotificationsHolder.beVisibleIf(isOreoPlus())
settingsCustomizeNotificationsHolder.setOnClickListener {
launchCustomizeNotificationsIntent()
}
}
private fun setupUseEnglish() = binding.apply {
settingsUseEnglishHolder.beVisibleIf((config.wasUseEnglishToggled || Locale.getDefault().language != "en") && !isTiramisuPlus())
settingsUseEnglish.isChecked = config.useEnglish
settingsUseEnglishHolder.setOnClickListener {
settingsUseEnglish.toggle()
config.useEnglish = settingsUseEnglish.isChecked
exitProcess(0)
}
}
private fun setupLanguage() = binding.apply {
settingsLanguage.text = Locale.getDefault().displayLanguage
settingsLanguageHolder.beVisibleIf(isTiramisuPlus())
settingsLanguageHolder.setOnClickListener {
launchChangeAppLanguageIntent()
}
}
// support for device-wise blocking came on Android 7, rely only on that
@TargetApi(Build.VERSION_CODES.N)
private fun setupManageBlockedNumbers() = binding.apply {
settingsManageBlockedNumbers.text = addLockedLabelIfNeeded(org.fossify.commons.R.string.manage_blocked_numbers)
settingsManageBlockedNumbersHolder.beVisibleIf(isNougatPlus())
settingsManageBlockedNumbersHolder.setOnClickListener {
if (isOrWasThankYouInstalled()) {
Intent(this@SettingsActivity, ManageBlockedNumbersActivity::class.java).apply {
startActivity(this)
}
} else {
FeatureLockedDialog(this@SettingsActivity) { }
}
}
}
private fun setupManageBlockedKeywords() = binding.apply {
settingsManageBlockedKeywords.text = addLockedLabelIfNeeded(R.string.manage_blocked_keywords)
settingsManageBlockedKeywordsHolder.setOnClickListener {
if (isOrWasThankYouInstalled()) {
Intent(this@SettingsActivity, ManageBlockedKeywordsActivity::class.java).apply {
startActivity(this)
}
} else {
FeatureLockedDialog(this@SettingsActivity) { }
}
}
}
private fun setupChangeDateTimeFormat() = binding.apply {
settingsChangeDateTimeFormatHolder.setOnClickListener {
ChangeDateTimeFormatDialog(this@SettingsActivity) {
refreshMessages()
}
}
}
private fun setupFontSize() = binding.apply {
settingsFontSize.text = getFontSizeText()
settingsFontSizeHolder.setOnClickListener {
val items = arrayListOf(
RadioItem(FONT_SIZE_SMALL, getString(org.fossify.commons.R.string.small)),
RadioItem(FONT_SIZE_MEDIUM, getString(org.fossify.commons.R.string.medium)),
RadioItem(FONT_SIZE_LARGE, getString(org.fossify.commons.R.string.large)),
RadioItem(FONT_SIZE_EXTRA_LARGE, getString(org.fossify.commons.R.string.extra_large))
)
RadioGroupDialog(this@SettingsActivity, items, config.fontSize) {
config.fontSize = it as Int
settingsFontSize.text = getFontSizeText()
}
}
}
private fun setupShowCharacterCounter() = binding.apply {
settingsShowCharacterCounter.isChecked = config.showCharacterCounter
settingsShowCharacterCounterHolder.setOnClickListener {
settingsShowCharacterCounter.toggle()
config.showCharacterCounter = settingsShowCharacterCounter.isChecked
}
}
private fun setupUseSimpleCharacters() = binding.apply {
settingsUseSimpleCharacters.isChecked = config.useSimpleCharacters
settingsUseSimpleCharactersHolder.setOnClickListener {
settingsUseSimpleCharacters.toggle()
config.useSimpleCharacters = settingsUseSimpleCharacters.isChecked
}
}
private fun setupSendOnEnter() = binding.apply {
settingsSendOnEnter.isChecked = config.sendOnEnter
settingsSendOnEnterHolder.setOnClickListener {
settingsSendOnEnter.toggle()
config.sendOnEnter = settingsSendOnEnter.isChecked
}
}
private fun setupEnableDeliveryReports() = binding.apply {
settingsEnableDeliveryReports.isChecked = config.enableDeliveryReports
settingsEnableDeliveryReportsHolder.setOnClickListener {
settingsEnableDeliveryReports.toggle()
config.enableDeliveryReports = settingsEnableDeliveryReports.isChecked
}
}
private fun setupSendLongMessageAsMMS() = binding.apply {
settingsSendLongMessageMms.isChecked = config.sendLongMessageMMS
settingsSendLongMessageMmsHolder.setOnClickListener {
settingsSendLongMessageMms.toggle()
config.sendLongMessageMMS = settingsSendLongMessageMms.isChecked
}
}
private fun setupGroupMessageAsMMS() = binding.apply {
settingsSendGroupMessageMms.isChecked = config.sendGroupMessageMMS
settingsSendGroupMessageMmsHolder.setOnClickListener {
settingsSendGroupMessageMms.toggle()
config.sendGroupMessageMMS = settingsSendGroupMessageMms.isChecked
}
}
private fun setupLockScreenVisibility() = binding.apply {
settingsLockScreenVisibility.text = getLockScreenVisibilityText()
settingsLockScreenVisibilityHolder.setOnClickListener {
val items = arrayListOf(
RadioItem(LOCK_SCREEN_SENDER_MESSAGE, getString(R.string.sender_and_message)),
RadioItem(LOCK_SCREEN_SENDER, getString(R.string.sender_only)),
RadioItem(LOCK_SCREEN_NOTHING, getString(org.fossify.commons.R.string.nothing)),
)
RadioGroupDialog(this@SettingsActivity, items, config.lockScreenVisibilitySetting) {
config.lockScreenVisibilitySetting = it as Int
settingsLockScreenVisibility.text = getLockScreenVisibilityText()
}
}
}
private fun getLockScreenVisibilityText() = getString(
when (config.lockScreenVisibilitySetting) {
LOCK_SCREEN_SENDER_MESSAGE -> R.string.sender_and_message
LOCK_SCREEN_SENDER -> R.string.sender_only
else -> org.fossify.commons.R.string.nothing
}
)
private fun setupMMSFileSizeLimit() = binding.apply {
settingsMmsFileSizeLimit.text = getMMSFileLimitText()
settingsMmsFileSizeLimitHolder.setOnClickListener {
val items = arrayListOf(
RadioItem(7, getString(R.string.mms_file_size_limit_none), FILE_SIZE_NONE),
RadioItem(6, getString(R.string.mms_file_size_limit_2mb), FILE_SIZE_2_MB),
RadioItem(5, getString(R.string.mms_file_size_limit_1mb), FILE_SIZE_1_MB),
RadioItem(4, getString(R.string.mms_file_size_limit_600kb), FILE_SIZE_600_KB),
RadioItem(3, getString(R.string.mms_file_size_limit_300kb), FILE_SIZE_300_KB),
RadioItem(2, getString(R.string.mms_file_size_limit_200kb), FILE_SIZE_200_KB),
RadioItem(1, getString(R.string.mms_file_size_limit_100kb), FILE_SIZE_100_KB),
)
val checkedItemId = items.find { it.value == config.mmsFileSizeLimit }?.id ?: 7
RadioGroupDialog(this@SettingsActivity, items, checkedItemId) {
config.mmsFileSizeLimit = it as Long
settingsMmsFileSizeLimit.text = getMMSFileLimitText()
}
}
}
private fun setupUseRecycleBin() = binding.apply {
updateRecycleBinButtons()
settingsUseRecycleBin.isChecked = config.useRecycleBin
settingsUseRecycleBinHolder.setOnClickListener {
settingsUseRecycleBin.toggle()
config.useRecycleBin = settingsUseRecycleBin.isChecked
updateRecycleBinButtons()
}
}
private fun updateRecycleBinButtons() = binding.apply {
settingsEmptyRecycleBinHolder.beVisibleIf(config.useRecycleBin)
}
private fun setupEmptyRecycleBin() = binding.apply {
ensureBackgroundThread {
recycleBinMessages = messagesDB.getArchivedCount()
runOnUiThread {
settingsEmptyRecycleBinSize.text =
resources.getQuantityString(R.plurals.delete_messages, recycleBinMessages, recycleBinMessages)
}
}
settingsEmptyRecycleBinHolder.setOnClickListener {
if (recycleBinMessages == 0) {
toast(org.fossify.commons.R.string.recycle_bin_empty)
} else {
ConfirmationDialog(
activity = this@SettingsActivity,
message = "",
messageId = R.string.empty_recycle_bin_messages_confirmation,
positive = org.fossify.commons.R.string.yes,
negative = org.fossify.commons.R.string.no
) {
ensureBackgroundThread {
emptyMessagesRecycleBin()
}
recycleBinMessages = 0
settingsEmptyRecycleBinSize.text =
resources.getQuantityString(R.plurals.delete_messages, recycleBinMessages, recycleBinMessages)
}
}
}
}
private fun setupAppPasswordProtection() = binding.apply {
settingsAppPasswordProtection.isChecked = config.isAppPasswordProtectionOn
settingsAppPasswordProtectionHolder.setOnClickListener {
val tabToShow = if (config.isAppPasswordProtectionOn) config.appProtectionType else SHOW_ALL_TABS
SecurityDialog(this@SettingsActivity, config.appPasswordHash, tabToShow) { hash, type, success ->
if (success) {
val hasPasswordProtection = config.isAppPasswordProtectionOn
settingsAppPasswordProtection.isChecked = !hasPasswordProtection
config.isAppPasswordProtectionOn = !hasPasswordProtection
config.appPasswordHash = if (hasPasswordProtection) "" else hash
config.appProtectionType = type
if (config.isAppPasswordProtectionOn) {
val confirmationTextId = if (config.appProtectionType == PROTECTION_FINGERPRINT) {
org.fossify.commons.R.string.fingerprint_setup_successfully
} else {
org.fossify.commons.R.string.protection_setup_successfully
}
ConfirmationDialog(this@SettingsActivity, "", confirmationTextId, org.fossify.commons.R.string.ok, 0) { }
}
}
}
}
}
private fun getMMSFileLimitText() = getString(
when (config.mmsFileSizeLimit) {
FILE_SIZE_100_KB -> R.string.mms_file_size_limit_100kb
FILE_SIZE_200_KB -> R.string.mms_file_size_limit_200kb
FILE_SIZE_300_KB -> R.string.mms_file_size_limit_300kb
FILE_SIZE_600_KB -> R.string.mms_file_size_limit_600kb
FILE_SIZE_1_MB -> R.string.mms_file_size_limit_1mb
FILE_SIZE_2_MB -> R.string.mms_file_size_limit_2mb
else -> R.string.mms_file_size_limit_none
}
)
}

View file

@ -0,0 +1,30 @@
package org.fossify.messages.activities
import org.fossify.commons.activities.BaseSimpleActivity
import org.fossify.messages.R
open class SimpleActivity : BaseSimpleActivity() {
override fun getAppIconIDs() = arrayListOf(
R.mipmap.ic_launcher_red,
R.mipmap.ic_launcher_pink,
R.mipmap.ic_launcher_purple,
R.mipmap.ic_launcher_deep_purple,
R.mipmap.ic_launcher_indigo,
R.mipmap.ic_launcher_blue,
R.mipmap.ic_launcher_light_blue,
R.mipmap.ic_launcher_cyan,
R.mipmap.ic_launcher_teal,
R.mipmap.ic_launcher,
R.mipmap.ic_launcher_light_green,
R.mipmap.ic_launcher_lime,
R.mipmap.ic_launcher_yellow,
R.mipmap.ic_launcher_amber,
R.mipmap.ic_launcher_orange,
R.mipmap.ic_launcher_deep_orange,
R.mipmap.ic_launcher_brown,
R.mipmap.ic_launcher_blue_grey,
R.mipmap.ic_launcher_grey_black
)
override fun getAppLauncherName() = getString(R.string.app_launcher_name)
}

View file

@ -0,0 +1,11 @@
package org.fossify.messages.activities
import android.content.Intent
import org.fossify.commons.activities.BaseSplashActivity
class SplashActivity : BaseSplashActivity() {
override fun initActivity() {
startActivity(Intent(this, MainActivity::class.java))
finish()
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,89 @@
package org.fossify.messages.activities
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import ezvcard.VCard
import ezvcard.property.Email
import ezvcard.property.Telephone
import org.fossify.commons.extensions.normalizePhoneNumber
import org.fossify.commons.extensions.sendEmailIntent
import org.fossify.commons.extensions.viewBinding
import org.fossify.commons.helpers.NavigationIcon
import org.fossify.messages.R
import org.fossify.messages.adapters.VCardViewerAdapter
import org.fossify.messages.databinding.ActivityVcardViewerBinding
import org.fossify.messages.extensions.dialNumber
import org.fossify.messages.helpers.EXTRA_VCARD_URI
import org.fossify.messages.helpers.parseVCardFromUri
import org.fossify.messages.models.VCardPropertyWrapper
import org.fossify.messages.models.VCardWrapper
class VCardViewerActivity : SimpleActivity() {
private val binding by viewBinding(ActivityVcardViewerBinding::inflate)
override fun onCreate(savedInstanceState: Bundle?) {
isMaterialActivity = true
super.onCreate(savedInstanceState)
setContentView(binding.root)
updateMaterialActivityViews(binding.vcardViewerCoordinator, binding.contactsList, useTransparentNavigation = true, useTopSearchMenu = false)
setupMaterialScrollListener(binding.contactsList, binding.vcardToolbar)
val vCardUri = intent.getParcelableExtra(EXTRA_VCARD_URI) as? Uri
if (vCardUri != null) {
setupOptionsMenu(vCardUri)
parseVCardFromUri(this, vCardUri) {
runOnUiThread {
setupContactsList(it)
}
}
}
}
override fun onResume() {
super.onResume()
setupToolbar(binding.vcardToolbar, NavigationIcon.Arrow)
}
private fun setupOptionsMenu(vCardUri: Uri) {
binding.vcardToolbar.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.add_contact -> {
val intent = Intent(Intent.ACTION_VIEW).apply {
val mimetype = contentResolver.getType(vCardUri)
setDataAndType(vCardUri, mimetype?.lowercase())
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(intent)
}
else -> return@setOnMenuItemClickListener false
}
return@setOnMenuItemClickListener true
}
}
private fun setupContactsList(vCards: List<VCard>) {
val items = prepareData(vCards)
val adapter = VCardViewerAdapter(this, items.toMutableList()) { item ->
val property = item as? VCardPropertyWrapper
if (property != null) {
handleClick(item)
}
}
binding.contactsList.adapter = adapter
}
private fun handleClick(property: VCardPropertyWrapper) {
when (property.property) {
is Telephone -> dialNumber(property.value.normalizePhoneNumber())
is Email -> sendEmailIntent(property.value)
}
}
private fun prepareData(vCards: List<VCard>): List<VCardWrapper> {
return vCards.map { vCard -> VCardWrapper.from(this, vCard) }
}
}

View file

@ -0,0 +1,96 @@
package org.fossify.messages.adapters
import android.view.Menu
import org.fossify.commons.dialogs.ConfirmationDialog
import org.fossify.commons.extensions.notificationManager
import org.fossify.commons.helpers.ensureBackgroundThread
import org.fossify.commons.views.MyRecyclerView
import org.fossify.messages.R
import org.fossify.messages.activities.SimpleActivity
import org.fossify.messages.extensions.deleteConversation
import org.fossify.messages.extensions.updateConversationArchivedStatus
import org.fossify.messages.helpers.refreshMessages
import org.fossify.messages.models.Conversation
class ArchivedConversationsAdapter(
activity: SimpleActivity, recyclerView: MyRecyclerView, onRefresh: () -> Unit, itemClick: (Any) -> Unit
) : BaseConversationsAdapter(activity, recyclerView, onRefresh, itemClick) {
override fun getActionMenuId() = R.menu.cab_archived_conversations
override fun prepareActionMode(menu: Menu) {}
override fun actionItemPressed(id: Int) {
if (selectedKeys.isEmpty()) {
return
}
when (id) {
R.id.cab_delete -> askConfirmDelete()
R.id.cab_unarchive -> unarchiveConversation()
R.id.cab_select_all -> selectAll()
}
}
private fun askConfirmDelete() {
val itemsCnt = selectedKeys.size
val items = resources.getQuantityString(R.plurals.delete_conversations, itemsCnt, itemsCnt)
val baseString = org.fossify.commons.R.string.deletion_confirmation
val question = String.format(resources.getString(baseString), items)
ConfirmationDialog(activity, question) {
ensureBackgroundThread {
deleteConversations()
}
}
}
private fun deleteConversations() {
if (selectedKeys.isEmpty()) {
return
}
val conversationsToRemove = currentList.filter { selectedKeys.contains(it.hashCode()) } as ArrayList<Conversation>
conversationsToRemove.forEach {
activity.deleteConversation(it.threadId)
activity.notificationManager.cancel(it.threadId.hashCode())
}
removeConversationsFromList(conversationsToRemove)
}
private fun unarchiveConversation() {
if (selectedKeys.isEmpty()) {
return
}
ensureBackgroundThread {
val conversationsToUnarchive = currentList.filter { selectedKeys.contains(it.hashCode()) } as ArrayList<Conversation>
conversationsToUnarchive.forEach {
activity.updateConversationArchivedStatus(it.threadId, false)
}
removeConversationsFromList(conversationsToUnarchive)
}
}
private fun removeConversationsFromList(removedConversations: List<Conversation>) {
val newList = try {
currentList.toMutableList().apply { removeAll(removedConversations) }
} catch (ignored: Exception) {
currentList.toMutableList()
}
activity.runOnUiThread {
if (newList.none { selectedKeys.contains(it.hashCode()) }) {
refreshMessages()
finishActMode()
} else {
submitList(newList)
if (newList.isEmpty()) {
refreshMessages()
}
}
}
}
}

View file

@ -0,0 +1,213 @@
package org.fossify.messages.adapters
import android.content.Intent
import android.graphics.drawable.Drawable
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.load.resource.bitmap.CenterCrop
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.request.target.Target
import org.fossify.commons.activities.BaseSimpleActivity
import org.fossify.commons.extensions.*
import org.fossify.messages.R
import org.fossify.messages.activities.VCardViewerActivity
import org.fossify.messages.databinding.ItemAttachmentDocumentPreviewBinding
import org.fossify.messages.databinding.ItemAttachmentMediaPreviewBinding
import org.fossify.messages.databinding.ItemAttachmentVcardPreviewBinding
import org.fossify.messages.extensions.*
import org.fossify.messages.helpers.*
import org.fossify.messages.models.AttachmentSelection
class AttachmentsAdapter(
val activity: BaseSimpleActivity,
val recyclerView: RecyclerView,
val onAttachmentsRemoved: () -> Unit,
val onReady: (() -> Unit)
) : ListAdapter<AttachmentSelection, AttachmentsAdapter.AttachmentsViewHolder>(AttachmentDiffCallback()) {
private val config = activity.config
private val resources = activity.resources
private val primaryColor = activity.getProperPrimaryColor()
private val imageCompressor by lazy { ImageCompressor(activity) }
val attachments = mutableListOf<AttachmentSelection>()
override fun getItemViewType(position: Int): Int {
return getItem(position).viewType
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AttachmentsViewHolder {
val inflater = LayoutInflater.from(parent.context)
val binding = when (viewType) {
ATTACHMENT_DOCUMENT -> ItemAttachmentDocumentPreviewBinding.inflate(inflater, parent, false)
ATTACHMENT_VCARD -> ItemAttachmentVcardPreviewBinding.inflate(inflater, parent, false)
ATTACHMENT_MEDIA -> ItemAttachmentMediaPreviewBinding.inflate(inflater, parent, false)
else -> throw IllegalArgumentException("Unknown view type: $viewType")
}
return AttachmentsViewHolder(binding)
}
override fun onBindViewHolder(holder: AttachmentsViewHolder, position: Int) {
val attachment = getItem(position)
holder.bindView { binding, _ ->
when (attachment.viewType) {
ATTACHMENT_DOCUMENT -> {
(binding as ItemAttachmentDocumentPreviewBinding).setupDocumentPreview(
uri = attachment.uri,
title = attachment.filename,
mimeType = attachment.mimetype,
onClick = { activity.launchViewIntent(attachment.uri, attachment.mimetype, attachment.filename) },
onRemoveButtonClicked = { removeAttachment(attachment) }
)
}
ATTACHMENT_VCARD -> {
(binding as ItemAttachmentVcardPreviewBinding).setupVCardPreview(
activity = activity,
uri = attachment.uri,
onClick = {
val intent = Intent(activity, VCardViewerActivity::class.java).also {
it.putExtra(EXTRA_VCARD_URI, attachment.uri)
}
activity.startActivity(intent)
},
onRemoveButtonClicked = { removeAttachment(attachment) }
)
}
ATTACHMENT_MEDIA -> setupMediaPreview(
binding = binding as ItemAttachmentMediaPreviewBinding,
attachment = attachment
)
}
}
}
fun clear() {
attachments.clear()
submitList(emptyList())
recyclerView.onGlobalLayout {
onAttachmentsRemoved()
}
}
fun addAttachment(attachment: AttachmentSelection) {
attachments.removeAll { AttachmentSelection.areItemsTheSame(it, attachment) }
attachments.add(attachment)
submitList(attachments.toList())
}
private fun removeAttachment(attachment: AttachmentSelection) {
attachments.removeAll { AttachmentSelection.areItemsTheSame(it, attachment) }
if (attachments.isEmpty()) {
clear()
} else {
submitList(attachments.toList())
}
}
private fun setupMediaPreview(binding: ItemAttachmentMediaPreviewBinding, attachment: AttachmentSelection) {
binding.apply {
mediaAttachmentHolder.background.applyColorFilter(primaryColor.darkenColor())
mediaAttachmentHolder.setOnClickListener {
activity.launchViewIntent(attachment.uri, attachment.mimetype, attachment.filename)
}
removeAttachmentButtonHolder.removeAttachmentButton.apply {
beVisible()
background.applyColorFilter(primaryColor)
setOnClickListener {
removeAttachment(attachment)
}
}
val compressImage = attachment.mimetype.isImageMimeType() && !attachment.mimetype.isGifMimeType()
if (compressImage && attachment.isPending && config.mmsFileSizeLimit != FILE_SIZE_NONE) {
thumbnail.beGone()
compressionProgress.beVisible()
imageCompressor.compressImage(attachment.uri, config.mmsFileSizeLimit) { compressedUri ->
activity.runOnUiThread {
when (compressedUri) {
attachment.uri -> {
attachments.find { it.uri == attachment.uri }?.isPending = false
loadMediaPreview(this, attachment)
}
null -> {
activity.toast(R.string.compress_error)
removeAttachment(attachment)
}
else -> {
attachments.remove(attachment)
addAttachment(attachment.copy(uri = compressedUri, isPending = false))
}
}
onReady()
}
}
} else {
loadMediaPreview(this, attachment)
}
}
}
private fun loadMediaPreview(binding: ItemAttachmentMediaPreviewBinding, attachment: AttachmentSelection) {
val roundedCornersRadius = resources.getDimension(org.fossify.commons.R.dimen.activity_margin).toInt()
val size = resources.getDimension(R.dimen.attachment_preview_size).toInt()
val options = RequestOptions()
.diskCacheStrategy(DiskCacheStrategy.NONE)
.transform(CenterCrop(), RoundedCorners(roundedCornersRadius))
Glide.with(binding.thumbnail)
.load(attachment.uri)
.transition(DrawableTransitionOptions.withCrossFade())
.override(size, size)
.apply(options)
.listener(object : RequestListener<Drawable> {
override fun onLoadFailed(e: GlideException?, model: Any?, target: Target<Drawable>, isFirstResource: Boolean): Boolean {
removeAttachment(attachment)
activity.toast(org.fossify.commons.R.string.unknown_error_occurred)
return false
}
override fun onResourceReady(dr: Drawable, a: Any, t: Target<Drawable>, d: DataSource, i: Boolean): Boolean {
binding.thumbnail.beVisible()
binding.playIcon.beVisibleIf(attachment.mimetype.isVideoMimeType())
binding.compressionProgress.beGone()
return false
}
})
.into(binding.thumbnail)
}
inner class AttachmentsViewHolder(val binding: ViewBinding) : RecyclerView.ViewHolder(binding.root) {
fun bindView(callback: (binding: ViewBinding, adapterPosition: Int) -> Unit) {
callback(binding, adapterPosition)
}
}
}
private class AttachmentDiffCallback : DiffUtil.ItemCallback<AttachmentSelection>() {
override fun areItemsTheSame(oldItem: AttachmentSelection, newItem: AttachmentSelection): Boolean {
return AttachmentSelection.areItemsTheSame(oldItem, newItem)
}
override fun areContentsTheSame(oldItem: AttachmentSelection, newItem: AttachmentSelection): Boolean {
return AttachmentSelection.areContentsTheSame(oldItem, newItem)
}
}

View file

@ -0,0 +1,87 @@
package org.fossify.messages.adapters
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.Filter
import org.fossify.commons.databinding.ItemContactWithNumberBinding
import org.fossify.commons.extensions.darkenColor
import org.fossify.commons.extensions.getContrastColor
import org.fossify.commons.extensions.getProperBackgroundColor
import org.fossify.commons.extensions.normalizeString
import org.fossify.commons.helpers.SimpleContactsHelper
import org.fossify.commons.models.SimpleContact
import org.fossify.messages.activities.SimpleActivity
class AutoCompleteTextViewAdapter(val activity: SimpleActivity, val contacts: ArrayList<SimpleContact>) : ArrayAdapter<SimpleContact>(activity, 0, contacts) {
var resultList = ArrayList<SimpleContact>()
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val contact = resultList.getOrNull(position)
var listItem = convertView
if (listItem == null || listItem.tag != contact?.name?.isNotEmpty()) {
listItem = ItemContactWithNumberBinding.inflate(LayoutInflater.from(activity), parent, false).root
}
listItem.tag = contact?.name?.isNotEmpty()
ItemContactWithNumberBinding.bind(listItem).apply {
// clickable and focusable properties seem to break Autocomplete clicking, so remove them
itemContactFrame.apply {
isClickable = false
isFocusable = false
}
val backgroundColor = activity.getProperBackgroundColor()
itemContactFrame.setBackgroundColor(backgroundColor.darkenColor())
itemContactName.setTextColor(backgroundColor.getContrastColor())
itemContactNumber.setTextColor(backgroundColor.getContrastColor())
if (contact != null) {
itemContactName.text = contact.name
itemContactNumber.text = contact.phoneNumbers.first().normalizedNumber
SimpleContactsHelper(context).loadContactImage(contact.photoUri, itemContactImage, contact.name)
}
}
return listItem
}
override fun getFilter() = object : Filter() {
override fun performFiltering(constraint: CharSequence?): FilterResults {
val filterResults = FilterResults()
if (constraint != null) {
val results = mutableListOf<SimpleContact>()
val searchString = constraint.toString().normalizeString()
contacts.forEach {
if (it.doesContainPhoneNumber(searchString) || it.name.contains(searchString, true)) {
results.add(it)
}
}
results.sortWith(compareBy { !it.name.startsWith(searchString, true) })
filterResults.values = results
filterResults.count = results.size
}
return filterResults
}
override fun publishResults(constraint: CharSequence?, results: FilterResults?) {
if (results != null && results.count > 0) {
resultList.clear()
@Suppress("UNCHECKED_CAST")
resultList.addAll(results.values as List<SimpleContact>)
notifyDataSetChanged()
} else {
notifyDataSetInvalidated()
}
}
override fun convertResultToString(resultValue: Any?) = (resultValue as? SimpleContact)?.name
}
override fun getItem(index: Int) = resultList[index]
override fun getCount() = resultList.size
}

View file

@ -0,0 +1,186 @@
package org.fossify.messages.adapters
import android.graphics.Typeface
import android.os.Parcelable
import android.util.TypedValue
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.qtalk.recyclerviewfastscroller.RecyclerViewFastScroller
import org.fossify.commons.adapters.MyRecyclerViewListAdapter
import org.fossify.commons.extensions.*
import org.fossify.commons.helpers.SimpleContactsHelper
import org.fossify.commons.helpers.ensureBackgroundThread
import org.fossify.commons.views.MyRecyclerView
import org.fossify.messages.activities.SimpleActivity
import org.fossify.messages.databinding.ItemConversationBinding
import org.fossify.messages.extensions.config
import org.fossify.messages.extensions.getAllDrafts
import org.fossify.messages.models.Conversation
@Suppress("LeakingThis")
abstract class BaseConversationsAdapter(
activity: SimpleActivity, recyclerView: MyRecyclerView, onRefresh: () -> Unit, itemClick: (Any) -> Unit
) : MyRecyclerViewListAdapter<Conversation>(activity, recyclerView, ConversationDiffCallback(), itemClick, onRefresh),
RecyclerViewFastScroller.OnPopupTextUpdate {
private var fontSize = activity.getTextSize()
private var drafts = HashMap<Long, String?>()
private var recyclerViewState: Parcelable? = null
init {
setupDragListener(true)
ensureBackgroundThread {
fetchDrafts(drafts)
}
setHasStableIds(true)
registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
override fun onChanged() = restoreRecyclerViewState()
override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) = restoreRecyclerViewState()
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) = restoreRecyclerViewState()
})
}
fun updateFontSize() {
fontSize = activity.getTextSize()
notifyDataSetChanged()
}
fun updateConversations(newConversations: ArrayList<Conversation>, commitCallback: (() -> Unit)? = null) {
saveRecyclerViewState()
submitList(newConversations.toList(), commitCallback)
}
fun updateDrafts() {
ensureBackgroundThread {
val newDrafts = HashMap<Long, String?>()
fetchDrafts(newDrafts)
if (drafts.hashCode() != newDrafts.hashCode()) {
drafts = newDrafts
activity.runOnUiThread {
notifyDataSetChanged()
}
}
}
}
override fun getSelectableItemCount() = itemCount
protected fun getSelectedItems() = currentList.filter { selectedKeys.contains(it.hashCode()) } as ArrayList<Conversation>
override fun getIsItemSelectable(position: Int) = true
override fun getItemSelectionKey(position: Int) = currentList.getOrNull(position)?.hashCode()
override fun getItemKeyPosition(key: Int) = currentList.indexOfFirst { it.hashCode() == key }
override fun onActionModeCreated() {}
override fun onActionModeDestroyed() {}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = ItemConversationBinding.inflate(layoutInflater, parent, false)
return createViewHolder(binding.root)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val conversation = getItem(position)
holder.bindView(conversation, allowSingleClick = true, allowLongClick = true) { itemView, _ ->
setupView(itemView, conversation)
}
bindViewHolder(holder)
}
override fun getItemId(position: Int) = getItem(position).threadId
override fun onViewRecycled(holder: ViewHolder) {
super.onViewRecycled(holder)
if (!activity.isDestroyed && !activity.isFinishing) {
val itemView = ItemConversationBinding.bind(holder.itemView)
Glide.with(activity).clear(itemView.conversationImage)
}
}
private fun fetchDrafts(drafts: HashMap<Long, String?>) {
drafts.clear()
for ((threadId, draft) in activity.getAllDrafts()) {
drafts[threadId] = draft
}
}
private fun setupView(view: View, conversation: Conversation) {
ItemConversationBinding.bind(view).apply {
root.setupViewBackground(activity)
val smsDraft = drafts[conversation.threadId]
draftIndicator.beVisibleIf(smsDraft != null)
draftIndicator.setTextColor(properPrimaryColor)
pinIndicator.beVisibleIf(activity.config.pinnedConversations.contains(conversation.threadId.toString()))
pinIndicator.applyColorFilter(textColor)
conversationFrame.isSelected = selectedKeys.contains(conversation.hashCode())
conversationAddress.apply {
text = conversation.title
setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize * 1.2f)
}
conversationBodyShort.apply {
text = smsDraft ?: conversation.snippet
setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize * 0.9f)
}
conversationDate.apply {
text = conversation.date.formatDateOrTime(context, true, false)
setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize * 0.8f)
}
val style = if (conversation.read) {
conversationBodyShort.alpha = 0.7f
if (conversation.isScheduled) Typeface.ITALIC else Typeface.NORMAL
} else {
conversationBodyShort.alpha = 1f
if (conversation.isScheduled) Typeface.BOLD_ITALIC else Typeface.BOLD
}
conversationAddress.setTypeface(null, style)
conversationBodyShort.setTypeface(null, style)
arrayListOf(conversationAddress, conversationBodyShort, conversationDate).forEach {
it.setTextColor(textColor)
}
// at group conversations we use an icon as the placeholder, not any letter
val placeholder = if (conversation.isGroupConversation) {
SimpleContactsHelper(activity).getColoredGroupIcon(conversation.title)
} else {
null
}
SimpleContactsHelper(activity).loadContactImage(conversation.photoUri, conversationImage, conversation.title, placeholder)
}
}
override fun onChange(position: Int) = currentList.getOrNull(position)?.title ?: ""
private fun saveRecyclerViewState() {
recyclerViewState = recyclerView.layoutManager?.onSaveInstanceState()
}
private fun restoreRecyclerViewState() {
recyclerView.layoutManager?.onRestoreInstanceState(recyclerViewState)
}
private class ConversationDiffCallback : DiffUtil.ItemCallback<Conversation>() {
override fun areItemsTheSame(oldItem: Conversation, newItem: Conversation): Boolean {
return Conversation.areItemsTheSame(oldItem, newItem)
}
override fun areContentsTheSame(oldItem: Conversation, newItem: Conversation): Boolean {
return Conversation.areContentsTheSame(oldItem, newItem)
}
}
}

View file

@ -0,0 +1,89 @@
package org.fossify.messages.adapters
import android.text.TextUtils
import android.util.TypedValue
import android.view.Menu
import android.view.View
import android.view.ViewGroup
import com.bumptech.glide.Glide
import org.fossify.commons.adapters.MyRecyclerViewAdapter
import org.fossify.commons.databinding.ItemContactWithNumberBinding
import org.fossify.commons.extensions.getTextSize
import org.fossify.commons.helpers.SimpleContactsHelper
import org.fossify.commons.models.SimpleContact
import org.fossify.commons.views.MyRecyclerView
import org.fossify.messages.activities.SimpleActivity
class ContactsAdapter(
activity: SimpleActivity, var contacts: ArrayList<SimpleContact>, recyclerView: MyRecyclerView, itemClick: (Any) -> Unit
) : MyRecyclerViewAdapter(activity, recyclerView, itemClick) {
private var fontSize = activity.getTextSize()
override fun getActionMenuId() = 0
override fun prepareActionMode(menu: Menu) {}
override fun actionItemPressed(id: Int) {}
override fun getSelectableItemCount() = contacts.size
override fun getIsItemSelectable(position: Int) = true
override fun getItemSelectionKey(position: Int) = contacts.getOrNull(position)?.rawId
override fun getItemKeyPosition(key: Int) = contacts.indexOfFirst { it.rawId == key }
override fun onActionModeCreated() {}
override fun onActionModeDestroyed() {}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = ItemContactWithNumberBinding.inflate(layoutInflater, parent, false)
return createViewHolder(binding.root)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val contact = contacts[position]
holder.bindView(contact, allowSingleClick = true, allowLongClick = false) { itemView, _ ->
setupView(itemView, contact)
}
bindViewHolder(holder)
}
override fun getItemCount() = contacts.size
fun updateContacts(newContacts: ArrayList<SimpleContact>) {
val oldHashCode = contacts.hashCode()
val newHashCode = newContacts.hashCode()
if (newHashCode != oldHashCode) {
contacts = newContacts
notifyDataSetChanged()
}
}
private fun setupView(view: View, contact: SimpleContact) {
ItemContactWithNumberBinding.bind(view).apply {
itemContactName.apply {
text = contact.name
setTextColor(textColor)
setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize * 1.2f)
}
itemContactNumber.apply {
text = TextUtils.join(", ", contact.phoneNumbers.map { it.normalizedNumber })
setTextColor(textColor)
setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize)
}
SimpleContactsHelper(activity).loadContactImage(contact.photoUri, itemContactImage, contact.name)
}
}
override fun onViewRecycled(holder: ViewHolder) {
super.onViewRecycled(holder)
if (!activity.isDestroyed && !activity.isFinishing) {
val binding = ItemContactWithNumberBinding.bind(holder.itemView)
Glide.with(activity).clear(binding.itemContactImage)
}
}
}

View file

@ -0,0 +1,293 @@
package org.fossify.messages.adapters
import android.content.Intent
import android.text.TextUtils
import android.view.Menu
import org.fossify.commons.dialogs.ConfirmationDialog
import org.fossify.commons.dialogs.FeatureLockedDialog
import org.fossify.commons.extensions.*
import org.fossify.commons.helpers.KEY_PHONE
import org.fossify.commons.helpers.ensureBackgroundThread
import org.fossify.commons.helpers.isNougatPlus
import org.fossify.commons.views.MyRecyclerView
import org.fossify.messages.R
import org.fossify.messages.activities.SimpleActivity
import org.fossify.messages.dialogs.RenameConversationDialog
import org.fossify.messages.extensions.*
import org.fossify.messages.helpers.refreshMessages
import org.fossify.messages.messaging.isShortCodeWithLetters
import org.fossify.messages.models.Conversation
class ConversationsAdapter(
activity: SimpleActivity, recyclerView: MyRecyclerView, onRefresh: () -> Unit, itemClick: (Any) -> Unit
) : BaseConversationsAdapter(activity, recyclerView, onRefresh, itemClick) {
override fun getActionMenuId() = R.menu.cab_conversations
override fun prepareActionMode(menu: Menu) {
val selectedItems = getSelectedItems()
val isSingleSelection = isOneItemSelected()
val selectedConversation = selectedItems.firstOrNull() ?: return
val isGroupConversation = selectedConversation.isGroupConversation
val archiveAvailable = activity.config.isArchiveAvailable
menu.apply {
findItem(R.id.cab_block_number).title = activity.addLockedLabelIfNeeded(org.fossify.commons.R.string.block_number)
findItem(R.id.cab_block_number).isVisible = isNougatPlus()
findItem(R.id.cab_add_number_to_contact).isVisible = isSingleSelection && !isGroupConversation
findItem(R.id.cab_dial_number).isVisible = isSingleSelection && !isGroupConversation && !isShortCodeWithLetters(selectedConversation.phoneNumber)
findItem(R.id.cab_copy_number).isVisible = isSingleSelection && !isGroupConversation
findItem(R.id.cab_rename_conversation).isVisible = isSingleSelection && isGroupConversation
findItem(R.id.cab_mark_as_read).isVisible = selectedItems.any { !it.read }
findItem(R.id.cab_mark_as_unread).isVisible = selectedItems.any { it.read }
findItem(R.id.cab_archive).isVisible = archiveAvailable
checkPinBtnVisibility(this)
}
}
override fun actionItemPressed(id: Int) {
if (selectedKeys.isEmpty()) {
return
}
when (id) {
R.id.cab_add_number_to_contact -> addNumberToContact()
R.id.cab_block_number -> tryBlocking()
R.id.cab_dial_number -> dialNumber()
R.id.cab_copy_number -> copyNumberToClipboard()
R.id.cab_delete -> askConfirmDelete()
R.id.cab_archive -> askConfirmArchive()
R.id.cab_rename_conversation -> renameConversation(getSelectedItems().first())
R.id.cab_mark_as_read -> markAsRead()
R.id.cab_mark_as_unread -> markAsUnread()
R.id.cab_pin_conversation -> pinConversation(true)
R.id.cab_unpin_conversation -> pinConversation(false)
R.id.cab_select_all -> selectAll()
}
}
private fun tryBlocking() {
if (activity.isOrWasThankYouInstalled()) {
askConfirmBlock()
} else {
FeatureLockedDialog(activity) { }
}
}
private fun askConfirmBlock() {
val numbers = getSelectedItems().distinctBy { it.phoneNumber }.map { it.phoneNumber }
val numbersString = TextUtils.join(", ", numbers)
val question = String.format(resources.getString(org.fossify.commons.R.string.block_confirmation), numbersString)
ConfirmationDialog(activity, question) {
blockNumbers()
}
}
private fun blockNumbers() {
if (selectedKeys.isEmpty()) {
return
}
val numbersToBlock = getSelectedItems()
val newList = currentList.toMutableList().apply { removeAll(numbersToBlock) }
ensureBackgroundThread {
numbersToBlock.map { it.phoneNumber }.forEach { number ->
activity.addBlockedNumber(number)
}
activity.runOnUiThread {
submitList(newList)
finishActMode()
}
}
}
private fun dialNumber() {
val conversation = getSelectedItems().firstOrNull() ?: return
activity.dialNumber(conversation.phoneNumber) {
finishActMode()
}
}
private fun copyNumberToClipboard() {
val conversation = getSelectedItems().firstOrNull() ?: return
activity.copyToClipboard(conversation.phoneNumber)
finishActMode()
}
private fun askConfirmDelete() {
val itemsCnt = selectedKeys.size
val items = resources.getQuantityString(R.plurals.delete_conversations, itemsCnt, itemsCnt)
val baseString = org.fossify.commons.R.string.deletion_confirmation
val question = String.format(resources.getString(baseString), items)
ConfirmationDialog(activity, question) {
ensureBackgroundThread {
deleteConversations()
}
}
}
private fun askConfirmArchive() {
val itemsCnt = selectedKeys.size
val items = resources.getQuantityString(R.plurals.delete_conversations, itemsCnt, itemsCnt)
val baseString = R.string.archive_confirmation
val question = String.format(resources.getString(baseString), items)
ConfirmationDialog(activity, question) {
ensureBackgroundThread {
archiveConversations()
}
}
}
private fun archiveConversations() {
if (selectedKeys.isEmpty()) {
return
}
val conversationsToRemove = currentList.filter { selectedKeys.contains(it.hashCode()) } as ArrayList<Conversation>
conversationsToRemove.forEach {
activity.updateConversationArchivedStatus(it.threadId, true)
activity.notificationManager.cancel(it.threadId.hashCode())
}
val newList = try {
currentList.toMutableList().apply { removeAll(conversationsToRemove) }
} catch (ignored: Exception) {
currentList.toMutableList()
}
activity.runOnUiThread {
if (newList.none { selectedKeys.contains(it.hashCode()) }) {
refreshMessages()
finishActMode()
} else {
submitList(newList)
if (newList.isEmpty()) {
refreshMessages()
}
}
}
}
private fun deleteConversations() {
if (selectedKeys.isEmpty()) {
return
}
val conversationsToRemove = currentList.filter { selectedKeys.contains(it.hashCode()) } as ArrayList<Conversation>
conversationsToRemove.forEach {
activity.deleteConversation(it.threadId)
activity.notificationManager.cancel(it.threadId.hashCode())
}
val newList = try {
currentList.toMutableList().apply { removeAll(conversationsToRemove) }
} catch (ignored: Exception) {
currentList.toMutableList()
}
activity.runOnUiThread {
if (newList.none { selectedKeys.contains(it.hashCode()) }) {
refreshMessages()
finishActMode()
} else {
submitList(newList)
if (newList.isEmpty()) {
refreshMessages()
}
}
}
}
private fun renameConversation(conversation: Conversation) {
RenameConversationDialog(activity, conversation) {
ensureBackgroundThread {
val updatedConv = activity.renameConversation(conversation, newTitle = it)
activity.runOnUiThread {
finishActMode()
currentList.toMutableList().apply {
set(indexOf(conversation), updatedConv)
updateConversations(this as ArrayList<Conversation>)
}
}
}
}
}
private fun markAsRead() {
if (selectedKeys.isEmpty()) {
return
}
val conversationsMarkedAsRead = currentList.filter { selectedKeys.contains(it.hashCode()) } as ArrayList<Conversation>
ensureBackgroundThread {
conversationsMarkedAsRead.filter { conversation -> !conversation.read }.forEach {
activity.markThreadMessagesRead(it.threadId)
}
refreshConversations()
}
}
private fun markAsUnread() {
if (selectedKeys.isEmpty()) {
return
}
val conversationsMarkedAsUnread = currentList.filter { selectedKeys.contains(it.hashCode()) } as ArrayList<Conversation>
ensureBackgroundThread {
conversationsMarkedAsUnread.filter { conversation -> conversation.read }.forEach {
activity.markThreadMessagesUnread(it.threadId)
}
refreshConversations()
}
}
private fun addNumberToContact() {
val conversation = getSelectedItems().firstOrNull() ?: return
Intent().apply {
action = Intent.ACTION_INSERT_OR_EDIT
type = "vnd.android.cursor.item/contact"
putExtra(KEY_PHONE, conversation.phoneNumber)
activity.launchActivityIntent(this)
}
}
private fun pinConversation(pin: Boolean) {
val conversations = getSelectedItems()
if (conversations.isEmpty()) {
return
}
if (pin) {
activity.config.addPinnedConversations(conversations)
} else {
activity.config.removePinnedConversations(conversations)
}
getSelectedItemPositions().forEach {
notifyItemChanged(it)
}
refreshConversations()
}
private fun checkPinBtnVisibility(menu: Menu) {
val pinnedConversations = activity.config.pinnedConversations
val selectedConversations = getSelectedItems()
menu.findItem(R.id.cab_pin_conversation).isVisible = selectedConversations.any { !pinnedConversations.contains(it.threadId.toString()) }
menu.findItem(R.id.cab_unpin_conversation).isVisible = selectedConversations.any { pinnedConversations.contains(it.threadId.toString()) }
}
private fun refreshConversations() {
activity.runOnUiThread {
refreshMessages()
finishActMode()
}
}
}

View file

@ -0,0 +1,108 @@
package org.fossify.messages.adapters
import android.view.Menu
import org.fossify.commons.dialogs.ConfirmationDialog
import org.fossify.commons.extensions.notificationManager
import org.fossify.commons.helpers.ensureBackgroundThread
import org.fossify.commons.views.MyRecyclerView
import org.fossify.messages.R
import org.fossify.messages.activities.SimpleActivity
import org.fossify.messages.extensions.deleteConversation
import org.fossify.messages.extensions.restoreAllMessagesFromRecycleBinForConversation
import org.fossify.messages.helpers.refreshMessages
import org.fossify.messages.models.Conversation
class RecycleBinConversationsAdapter(
activity: SimpleActivity, recyclerView: MyRecyclerView, onRefresh: () -> Unit, itemClick: (Any) -> Unit
) : BaseConversationsAdapter(activity, recyclerView, onRefresh, itemClick) {
override fun getActionMenuId() = R.menu.cab_recycle_bin_conversations
override fun prepareActionMode(menu: Menu) {}
override fun actionItemPressed(id: Int) {
if (selectedKeys.isEmpty()) {
return
}
when (id) {
R.id.cab_delete -> askConfirmDelete()
R.id.cab_restore -> askConfirmRestore()
R.id.cab_select_all -> selectAll()
}
}
private fun askConfirmDelete() {
val itemsCnt = selectedKeys.size
val items = resources.getQuantityString(R.plurals.delete_conversations, itemsCnt, itemsCnt)
val baseString = org.fossify.commons.R.string.deletion_confirmation
val question = String.format(resources.getString(baseString), items)
ConfirmationDialog(activity, question) {
ensureBackgroundThread {
deleteConversations()
}
}
}
private fun deleteConversations() {
if (selectedKeys.isEmpty()) {
return
}
val conversationsToRemove = currentList.filter { selectedKeys.contains(it.hashCode()) } as ArrayList<Conversation>
conversationsToRemove.forEach {
activity.deleteConversation(it.threadId)
activity.notificationManager.cancel(it.threadId.hashCode())
}
removeConversationsFromList(conversationsToRemove)
}
private fun askConfirmRestore() {
val itemsCnt = selectedKeys.size
val items = resources.getQuantityString(R.plurals.delete_conversations, itemsCnt, itemsCnt)
val baseString = R.string.restore_confirmation
val question = String.format(resources.getString(baseString), items)
ConfirmationDialog(activity, question) {
ensureBackgroundThread {
restoreConversations()
}
}
}
private fun restoreConversations() {
if (selectedKeys.isEmpty()) {
return
}
val conversationsToRemove = currentList.filter { selectedKeys.contains(it.hashCode()) } as ArrayList<Conversation>
conversationsToRemove.forEach {
activity.restoreAllMessagesFromRecycleBinForConversation(it.threadId)
}
removeConversationsFromList(conversationsToRemove)
}
private fun removeConversationsFromList(removedConversations: List<Conversation>) {
val newList = try {
currentList.toMutableList().apply { removeAll(removedConversations) }
} catch (ignored: Exception) {
currentList.toMutableList()
}
activity.runOnUiThread {
if (newList.none { selectedKeys.contains(it.hashCode()) }) {
refreshMessages()
finishActMode()
} else {
submitList(newList)
if (newList.isEmpty()) {
refreshMessages()
}
}
}
}
}

View file

@ -0,0 +1,99 @@
package org.fossify.messages.adapters
import android.util.TypedValue
import android.view.Menu
import android.view.View
import android.view.ViewGroup
import com.bumptech.glide.Glide
import org.fossify.commons.adapters.MyRecyclerViewAdapter
import org.fossify.commons.extensions.getTextSize
import org.fossify.commons.extensions.highlightTextPart
import org.fossify.commons.helpers.SimpleContactsHelper
import org.fossify.commons.views.MyRecyclerView
import org.fossify.messages.activities.SimpleActivity
import org.fossify.messages.databinding.ItemSearchResultBinding
import org.fossify.messages.models.SearchResult
class SearchResultsAdapter(
activity: SimpleActivity, var searchResults: ArrayList<SearchResult>, recyclerView: MyRecyclerView, highlightText: String, itemClick: (Any) -> Unit
) : MyRecyclerViewAdapter(activity, recyclerView, itemClick) {
private var fontSize = activity.getTextSize()
private var textToHighlight = highlightText
override fun getActionMenuId() = 0
override fun prepareActionMode(menu: Menu) {}
override fun actionItemPressed(id: Int) {}
override fun getSelectableItemCount() = searchResults.size
override fun getIsItemSelectable(position: Int) = false
override fun getItemSelectionKey(position: Int) = searchResults.getOrNull(position)?.hashCode()
override fun getItemKeyPosition(key: Int) = searchResults.indexOfFirst { it.hashCode() == key }
override fun onActionModeCreated() {}
override fun onActionModeDestroyed() {}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = ItemSearchResultBinding.inflate(layoutInflater, parent, false)
return createViewHolder(binding.root)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val searchResult = searchResults[position]
holder.bindView(searchResult, allowSingleClick = true, allowLongClick = false) { itemView, _ ->
setupView(itemView, searchResult)
}
bindViewHolder(holder)
}
override fun getItemCount() = searchResults.size
fun updateItems(newItems: ArrayList<SearchResult>, highlightText: String = "") {
if (newItems.hashCode() != searchResults.hashCode()) {
searchResults = newItems.clone() as ArrayList<SearchResult>
textToHighlight = highlightText
notifyDataSetChanged()
} else if (textToHighlight != highlightText) {
textToHighlight = highlightText
notifyDataSetChanged()
}
}
private fun setupView(view: View, searchResult: SearchResult) {
ItemSearchResultBinding.bind(view).apply {
searchResultTitle.apply {
text = searchResult.title.highlightTextPart(textToHighlight, properPrimaryColor)
setTextColor(textColor)
setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize * 1.2f)
}
searchResultSnippet.apply {
text = searchResult.snippet.highlightTextPart(textToHighlight, properPrimaryColor)
setTextColor(textColor)
setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize * 0.9f)
}
searchResultDate.apply {
text = searchResult.date
setTextColor(textColor)
setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize * 0.8f)
}
SimpleContactsHelper(activity).loadContactImage(searchResult.photoUri, searchResultImage, searchResult.title)
}
}
override fun onViewRecycled(holder: ViewHolder) {
super.onViewRecycled(holder)
if (!activity.isDestroyed && !activity.isFinishing) {
val binding = ItemSearchResultBinding.bind(holder.itemView)
Glide.with(activity).clear(binding.searchResultImage)
}
}
}

View file

@ -0,0 +1,585 @@
package org.fossify.messages.adapters
import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Color
import android.graphics.Typeface
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.util.Size
import android.util.TypedValue
import android.view.Menu
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.RelativeLayout
import androidx.appcompat.content.res.AppCompatResources
import androidx.constraintlayout.widget.ConstraintSet
import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.DiffUtil
import androidx.viewbinding.ViewBinding
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.load.resource.bitmap.CenterCrop
import com.bumptech.glide.load.resource.bitmap.FitCenter
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.request.target.Target
import org.fossify.commons.adapters.MyRecyclerViewListAdapter
import org.fossify.commons.dialogs.ConfirmationDialog
import org.fossify.commons.extensions.*
import org.fossify.commons.helpers.SimpleContactsHelper
import org.fossify.commons.helpers.ensureBackgroundThread
import org.fossify.commons.views.MyRecyclerView
import org.fossify.messages.R
import org.fossify.messages.activities.NewConversationActivity
import org.fossify.messages.activities.SimpleActivity
import org.fossify.messages.activities.ThreadActivity
import org.fossify.messages.activities.VCardViewerActivity
import org.fossify.messages.databinding.*
import org.fossify.messages.dialogs.DeleteConfirmationDialog
import org.fossify.messages.dialogs.MessageDetailsDialog
import org.fossify.messages.dialogs.SelectTextDialog
import org.fossify.messages.extensions.*
import org.fossify.messages.helpers.*
import org.fossify.messages.models.Attachment
import org.fossify.messages.models.Message
import org.fossify.messages.models.ThreadItem
import org.fossify.messages.models.ThreadItem.*
class ThreadAdapter(
activity: SimpleActivity,
recyclerView: MyRecyclerView,
itemClick: (Any) -> Unit,
val isRecycleBin: Boolean,
val deleteMessages: (messages: List<Message>, toRecycleBin: Boolean, fromRecycleBin: Boolean) -> Unit
) : MyRecyclerViewListAdapter<ThreadItem>(activity, recyclerView, ThreadItemDiffCallback(), itemClick) {
private var fontSize = activity.getTextSize()
@SuppressLint("MissingPermission")
private val hasMultipleSIMCards = (activity.subscriptionManagerCompat().activeSubscriptionInfoList?.size ?: 0) > 1
private val maxChatBubbleWidth = activity.usableScreenSize.x * 0.8f
init {
setupDragListener(true)
setHasStableIds(true)
}
override fun getActionMenuId() = R.menu.cab_thread
override fun prepareActionMode(menu: Menu) {
val isOneItemSelected = isOneItemSelected()
val selectedItem = getSelectedItems().firstOrNull() as? Message
val hasText = selectedItem?.body != null && selectedItem.body != ""
menu.apply {
findItem(R.id.cab_copy_to_clipboard).isVisible = isOneItemSelected && hasText
findItem(R.id.cab_save_as).isVisible = isOneItemSelected && selectedItem?.attachment?.attachments?.size == 1
findItem(R.id.cab_share).isVisible = isOneItemSelected && hasText
findItem(R.id.cab_forward_message).isVisible = isOneItemSelected
findItem(R.id.cab_select_text).isVisible = isOneItemSelected && hasText
findItem(R.id.cab_properties).isVisible = isOneItemSelected
findItem(R.id.cab_restore).isVisible = isRecycleBin
}
}
override fun actionItemPressed(id: Int) {
if (selectedKeys.isEmpty()) {
return
}
when (id) {
R.id.cab_copy_to_clipboard -> copyToClipboard()
R.id.cab_save_as -> saveAs()
R.id.cab_share -> shareText()
R.id.cab_forward_message -> forwardMessage()
R.id.cab_select_text -> selectText()
R.id.cab_delete -> askConfirmDelete()
R.id.cab_restore -> askConfirmRestore()
R.id.cab_select_all -> selectAll()
R.id.cab_properties -> showMessageDetails()
}
}
override fun getSelectableItemCount() = currentList.filterIsInstance<Message>().size
override fun getIsItemSelectable(position: Int) = !isThreadDateTime(position)
override fun getItemSelectionKey(position: Int) = (currentList.getOrNull(position) as? Message)?.hashCode()
override fun getItemKeyPosition(key: Int) = currentList.indexOfFirst { (it as? Message)?.hashCode() == key }
override fun onActionModeCreated() {}
override fun onActionModeDestroyed() {}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = when (viewType) {
THREAD_LOADING -> ItemThreadLoadingBinding.inflate(layoutInflater, parent, false)
THREAD_DATE_TIME -> ItemThreadDateTimeBinding.inflate(layoutInflater, parent, false)
THREAD_SENT_MESSAGE_ERROR -> ItemThreadErrorBinding.inflate(layoutInflater, parent, false)
THREAD_SENT_MESSAGE_SENT -> ItemThreadSuccessBinding.inflate(layoutInflater, parent, false)
THREAD_SENT_MESSAGE_SENDING -> ItemThreadSendingBinding.inflate(layoutInflater, parent, false)
else -> ItemMessageBinding.inflate(layoutInflater, parent, false)
}
return ThreadViewHolder(binding)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = getItem(position)
val isClickable = item is ThreadError || item is Message
val isLongClickable = item is Message
holder.bindView(item, isClickable, isLongClickable) { itemView, _ ->
when (item) {
is ThreadLoading -> setupThreadLoading(itemView)
is ThreadDateTime -> setupDateTime(itemView, item)
is ThreadError -> setupThreadError(itemView)
is ThreadSent -> setupThreadSuccess(itemView, item.delivered)
is ThreadSending -> setupThreadSending(itemView)
is Message -> setupView(holder, itemView, item)
}
}
bindViewHolder(holder)
}
override fun getItemId(position: Int): Long {
return when (val item = getItem(position)) {
is Message -> Message.getStableId(item)
else -> item.hashCode().toLong()
}
}
override fun getItemViewType(position: Int): Int {
return when (val item = getItem(position)) {
is ThreadLoading -> THREAD_LOADING
is ThreadDateTime -> THREAD_DATE_TIME
is ThreadError -> THREAD_SENT_MESSAGE_ERROR
is ThreadSent -> THREAD_SENT_MESSAGE_SENT
is ThreadSending -> THREAD_SENT_MESSAGE_SENDING
is Message -> if (item.isReceivedMessage()) THREAD_RECEIVED_MESSAGE else THREAD_SENT_MESSAGE
}
}
private fun copyToClipboard() {
val firstItem = getSelectedItems().firstOrNull() as? Message ?: return
activity.copyToClipboard(firstItem.body)
}
private fun saveAs() {
val firstItem = getSelectedItems().firstOrNull() as? Message ?: return
val attachment = firstItem.attachment?.attachments?.first() ?: return
(activity as ThreadActivity).saveMMS(attachment.mimetype, attachment.uriString)
}
private fun shareText() {
val firstItem = getSelectedItems().firstOrNull() as? Message ?: return
activity.shareTextIntent(firstItem.body)
}
private fun selectText() {
val firstItem = getSelectedItems().firstOrNull() as? Message ?: return
if (firstItem.body.trim().isNotEmpty()) {
SelectTextDialog(activity, firstItem.body)
}
}
private fun showMessageDetails() {
val message = getSelectedItems().firstOrNull() as? Message ?: return
MessageDetailsDialog(activity, message)
}
private fun askConfirmDelete() {
val itemsCnt = selectedKeys.size
// not sure how we can get UnknownFormatConversionException here, so show the error and hope that someone reports it
val items = try {
resources.getQuantityString(R.plurals.delete_messages, itemsCnt, itemsCnt)
} catch (e: Exception) {
activity.showErrorToast(e)
return
}
val baseString = if (activity.config.useRecycleBin && !isRecycleBin) {
org.fossify.commons.R.string.move_to_recycle_bin_confirmation
} else {
org.fossify.commons.R.string.deletion_confirmation
}
val question = String.format(resources.getString(baseString), items)
DeleteConfirmationDialog(activity, question, activity.config.useRecycleBin && !isRecycleBin) { skipRecycleBin ->
ensureBackgroundThread {
val messagesToRemove = getSelectedItems()
if (messagesToRemove.isNotEmpty()) {
val toRecycleBin = !skipRecycleBin && activity.config.useRecycleBin && !isRecycleBin
deleteMessages(messagesToRemove.filterIsInstance<Message>(), toRecycleBin, false)
}
}
}
}
private fun askConfirmRestore() {
val itemsCnt = selectedKeys.size
// not sure how we can get UnknownFormatConversionException here, so show the error and hope that someone reports it
val items = try {
resources.getQuantityString(R.plurals.delete_messages, itemsCnt, itemsCnt)
} catch (e: Exception) {
activity.showErrorToast(e)
return
}
val baseString = R.string.restore_confirmation
val question = String.format(resources.getString(baseString), items)
ConfirmationDialog(activity, question) {
ensureBackgroundThread {
val messagesToRestore = getSelectedItems()
if (messagesToRestore.isNotEmpty()) {
deleteMessages(messagesToRestore.filterIsInstance<Message>(), false, true)
}
}
}
}
private fun forwardMessage() {
val message = getSelectedItems().firstOrNull() as? Message ?: return
val attachment = message.attachment?.attachments?.firstOrNull()
Intent(activity, NewConversationActivity::class.java).apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, message.body)
if (attachment != null) {
putExtra(Intent.EXTRA_STREAM, attachment.getUri())
}
activity.startActivity(this)
}
}
private fun getSelectedItems() = currentList.filter { selectedKeys.contains((it as? Message)?.hashCode() ?: 0) } as ArrayList<ThreadItem>
private fun isThreadDateTime(position: Int) = currentList.getOrNull(position) is ThreadDateTime
fun updateMessages(newMessages: ArrayList<ThreadItem>, scrollPosition: Int = -1) {
val latestMessages = newMessages.toMutableList()
submitList(latestMessages) {
if (scrollPosition != -1) {
recyclerView.scrollToPosition(scrollPosition)
}
}
}
private fun setupView(holder: ViewHolder, view: View, message: Message) {
ItemMessageBinding.bind(view).apply {
threadMessageHolder.isSelected = selectedKeys.contains(message.hashCode())
threadMessageBody.apply {
text = message.body
setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize)
beVisibleIf(message.body.isNotEmpty())
setOnLongClickListener {
holder.viewLongClicked()
true
}
setOnClickListener {
holder.viewClicked(message)
}
}
if (message.isReceivedMessage()) {
setupReceivedMessageView(messageBinding = this, message = message)
} else {
setupSentMessageView(messageBinding = this, message = message)
}
if (message.attachment?.attachments?.isNotEmpty() == true) {
threadMessageAttachmentsHolder.beVisible()
threadMessageAttachmentsHolder.removeAllViews()
for (attachment in message.attachment.attachments) {
val mimetype = attachment.mimetype
when {
mimetype.isImageMimeType() || mimetype.isVideoMimeType() -> setupImageView(holder, binding = this, message, attachment)
mimetype.isVCardMimeType() -> setupVCardView(holder, threadMessageAttachmentsHolder, message, attachment)
else -> setupFileView(holder, threadMessageAttachmentsHolder, message, attachment)
}
threadMessagePlayOutline.beVisibleIf(mimetype.startsWith("video/"))
}
} else {
threadMessageAttachmentsHolder.beGone()
threadMessagePlayOutline.beGone()
}
}
}
private fun setupReceivedMessageView(messageBinding: ItemMessageBinding, message: Message) {
messageBinding.apply {
with(ConstraintSet()) {
clone(threadMessageHolder)
clear(threadMessageWrapper.id, ConstraintSet.END)
connect(threadMessageWrapper.id, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START)
applyTo(threadMessageHolder)
}
threadMessageSenderPhoto.beVisible()
threadMessageSenderPhoto.setOnClickListener {
val contact = message.getSender()!!
activity.getContactFromAddress(contact.phoneNumbers.first().normalizedNumber) {
if (it != null) {
activity.startContactDetailsIntent(it)
}
}
}
threadMessageBody.apply {
background = AppCompatResources.getDrawable(activity, R.drawable.item_received_background)
setTextColor(textColor)
setLinkTextColor(activity.getProperPrimaryColor())
}
if (!activity.isFinishing && !activity.isDestroyed) {
val contactLetterIcon = SimpleContactsHelper(activity).getContactLetterIcon(message.senderName)
val placeholder = BitmapDrawable(activity.resources, contactLetterIcon)
val options = RequestOptions()
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.error(placeholder)
.centerCrop()
Glide.with(activity)
.load(message.senderPhotoUri)
.placeholder(placeholder)
.apply(options)
.apply(RequestOptions.circleCropTransform())
.into(threadMessageSenderPhoto)
}
}
}
private fun setupSentMessageView(messageBinding: ItemMessageBinding, message: Message) {
messageBinding.apply {
with(ConstraintSet()) {
clone(threadMessageHolder)
clear(threadMessageWrapper.id, ConstraintSet.START)
connect(threadMessageWrapper.id, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END)
applyTo(threadMessageHolder)
}
val primaryColor = activity.getProperPrimaryColor()
val contrastColor = primaryColor.getContrastColor()
threadMessageBody.apply {
updateLayoutParams<RelativeLayout.LayoutParams> {
removeRule(RelativeLayout.END_OF)
addRule(RelativeLayout.ALIGN_PARENT_END)
}
background = AppCompatResources.getDrawable(activity, R.drawable.item_sent_background)
background.applyColorFilter(primaryColor)
setTextColor(contrastColor)
setLinkTextColor(contrastColor)
if (message.isScheduled) {
typeface = Typeface.create(Typeface.DEFAULT, Typeface.ITALIC)
val scheduledDrawable = AppCompatResources.getDrawable(activity, org.fossify.commons.R.drawable.ic_clock_vector)?.apply {
applyColorFilter(contrastColor)
val size = lineHeight
setBounds(0, 0, size, size)
}
setCompoundDrawables(null, null, scheduledDrawable, null)
} else {
typeface = Typeface.DEFAULT
setCompoundDrawables(null, null, null, null)
}
}
}
}
private fun setupImageView(holder: ViewHolder, binding: ItemMessageBinding, message: Message, attachment: Attachment) = binding.apply {
val mimetype = attachment.mimetype
val uri = attachment.getUri()
val imageView = ItemAttachmentImageBinding.inflate(layoutInflater)
threadMessageAttachmentsHolder.addView(imageView.root)
val placeholderDrawable = ColorDrawable(Color.TRANSPARENT)
val isTallImage = attachment.height > attachment.width
val transformation = if (isTallImage) CenterCrop() else FitCenter()
val options = RequestOptions()
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.placeholder(placeholderDrawable)
.transform(transformation)
var builder = Glide.with(root.context)
.load(uri)
.apply(options)
.listener(object : RequestListener<Drawable> {
override fun onLoadFailed(e: GlideException?, model: Any?, target: Target<Drawable>, isFirstResource: Boolean): Boolean {
threadMessagePlayOutline.beGone()
threadMessageAttachmentsHolder.removeView(imageView.root)
return false
}
override fun onResourceReady(dr: Drawable, a: Any, t: Target<Drawable>, d: DataSource, i: Boolean) = false
})
// limit attachment sizes to avoid causing OOM
var wantedAttachmentSize = Size(attachment.width, attachment.height)
if (wantedAttachmentSize.width > maxChatBubbleWidth) {
val newHeight = wantedAttachmentSize.height / (wantedAttachmentSize.width / maxChatBubbleWidth)
wantedAttachmentSize = Size(maxChatBubbleWidth.toInt(), newHeight.toInt())
}
builder = if (isTallImage) {
builder.override(wantedAttachmentSize.width, wantedAttachmentSize.width)
} else {
builder.override(wantedAttachmentSize.width, wantedAttachmentSize.height)
}
try {
builder.into(imageView.attachmentImage)
} catch (ignore: Exception) {
}
imageView.attachmentImage.setOnClickListener {
if (actModeCallback.isSelectable) {
holder.viewClicked(message)
} else {
activity.launchViewIntent(uri, mimetype, attachment.filename)
}
}
imageView.root.setOnLongClickListener {
holder.viewLongClicked()
true
}
}
private fun setupVCardView(holder: ViewHolder, parent: LinearLayout, message: Message, attachment: Attachment) {
val uri = attachment.getUri()
val vCardView = ItemAttachmentVcardBinding.inflate(layoutInflater).apply {
setupVCardPreview(
activity = activity,
uri = uri,
onClick = {
if (actModeCallback.isSelectable) {
holder.viewClicked(message)
} else {
val intent = Intent(activity, VCardViewerActivity::class.java).also {
it.putExtra(EXTRA_VCARD_URI, uri)
}
activity.startActivity(intent)
}
},
onLongClick = { holder.viewLongClicked() }
)
}.root
parent.addView(vCardView)
}
private fun setupFileView(holder: ViewHolder, parent: LinearLayout, message: Message, attachment: Attachment) {
val mimetype = attachment.mimetype
val uri = attachment.getUri()
val attachmentView = ItemAttachmentDocumentBinding.inflate(layoutInflater).apply {
setupDocumentPreview(
uri = uri,
title = attachment.filename,
mimeType = attachment.mimetype,
onClick = {
if (actModeCallback.isSelectable) {
holder.viewClicked(message)
} else {
activity.launchViewIntent(uri, mimetype, attachment.filename)
}
},
onLongClick = { holder.viewLongClicked() }
)
}.root
parent.addView(attachmentView)
}
private fun setupDateTime(view: View, dateTime: ThreadDateTime) {
ItemThreadDateTimeBinding.bind(view).apply {
threadDateTime.apply {
text = dateTime.date.formatDateOrTime(context, hideTimeAtOtherDays = false, showYearEvenIfCurrent = false)
setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize)
}
threadDateTime.setTextColor(textColor)
threadSimIcon.beVisibleIf(hasMultipleSIMCards)
threadSimNumber.beVisibleIf(hasMultipleSIMCards)
if (hasMultipleSIMCards) {
threadSimNumber.text = dateTime.simID
threadSimNumber.setTextColor(textColor.getContrastColor())
threadSimIcon.applyColorFilter(textColor)
}
}
}
private fun setupThreadSuccess(view: View, isDelivered: Boolean) {
ItemThreadSuccessBinding.bind(view).apply {
threadSuccess.setImageResource(if (isDelivered) R.drawable.ic_check_double_vector else org.fossify.commons.R.drawable.ic_check_vector)
threadSuccess.applyColorFilter(textColor)
}
}
private fun setupThreadError(view: View) {
val binding = ItemThreadErrorBinding.bind(view)
binding.threadError.setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize - 4)
}
private fun setupThreadSending(view: View) {
ItemThreadSendingBinding.bind(view).threadSending.apply {
setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize)
setTextColor(textColor)
}
}
private fun setupThreadLoading(view: View) {
val binding = ItemThreadLoadingBinding.bind(view)
binding.threadLoading.setIndicatorColor(properPrimaryColor)
}
override fun onViewRecycled(holder: ViewHolder) {
super.onViewRecycled(holder)
if (!activity.isDestroyed && !activity.isFinishing) {
val binding = (holder as ThreadViewHolder).binding
if (binding is ItemMessageBinding) {
Glide.with(activity).clear(binding.threadMessageSenderPhoto)
}
}
}
inner class ThreadViewHolder(val binding: ViewBinding) : ViewHolder(binding.root)
}
private class ThreadItemDiffCallback : DiffUtil.ItemCallback<ThreadItem>() {
override fun areItemsTheSame(oldItem: ThreadItem, newItem: ThreadItem): Boolean {
if (oldItem::class.java != newItem::class.java) return false
return when (oldItem) {
is ThreadLoading -> oldItem.id == (newItem as ThreadLoading).id
is ThreadDateTime -> oldItem.date == (newItem as ThreadDateTime).date
is ThreadError -> oldItem.messageId == (newItem as ThreadError).messageId
is ThreadSent -> oldItem.messageId == (newItem as ThreadSent).messageId
is ThreadSending -> oldItem.messageId == (newItem as ThreadSending).messageId
is Message -> Message.areItemsTheSame(oldItem, newItem as Message)
}
}
override fun areContentsTheSame(oldItem: ThreadItem, newItem: ThreadItem): Boolean {
if (oldItem::class.java != newItem::class.java) return false
return when (oldItem) {
is ThreadLoading, is ThreadSending -> true
is ThreadDateTime -> oldItem.simID == (newItem as ThreadDateTime).simID
is ThreadError -> oldItem.messageText == (newItem as ThreadError).messageText
is ThreadSent -> oldItem.delivered == (newItem as ThreadSent).delivered
is Message -> Message.areContentsTheSame(oldItem, newItem as Message)
}
}
}

View file

@ -0,0 +1,163 @@
package org.fossify.messages.adapters
import android.util.TypedValue
import android.view.ViewGroup
import androidx.core.graphics.drawable.toDrawable
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
import com.bumptech.glide.request.RequestOptions
import org.fossify.commons.extensions.*
import org.fossify.commons.helpers.SimpleContactsHelper
import org.fossify.messages.R
import org.fossify.messages.activities.SimpleActivity
import org.fossify.messages.databinding.ItemVcardContactBinding
import org.fossify.messages.databinding.ItemVcardContactPropertyBinding
import org.fossify.messages.models.VCardPropertyWrapper
import org.fossify.messages.models.VCardWrapper
private const val TYPE_VCARD_CONTACT = 1
private const val TYPE_VCARD_CONTACT_PROPERTY = 2
class VCardViewerAdapter(
activity: SimpleActivity, private var items: MutableList<Any>, private val itemClick: (Any) -> Unit
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private var fontSize = activity.getTextSize()
private var textColor = activity.getProperTextColor()
private val layoutInflater = activity.layoutInflater
override fun getItemCount() = items.size
override fun getItemViewType(position: Int): Int {
return when (val item = items[position]) {
is VCardWrapper -> TYPE_VCARD_CONTACT
is VCardPropertyWrapper -> TYPE_VCARD_CONTACT_PROPERTY
else -> throw IllegalArgumentException("Unexpected type: ${item::class.simpleName}")
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
TYPE_VCARD_CONTACT -> VCardContactViewHolder(
binding = ItemVcardContactBinding.inflate(layoutInflater, parent, false)
)
TYPE_VCARD_CONTACT_PROPERTY -> VCardPropertyViewHolder(
binding = ItemVcardContactPropertyBinding.inflate(layoutInflater, parent, false)
)
else -> throw IllegalArgumentException("Unexpected type: $viewType")
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val item = items[position]
when (holder) {
is VCardContactViewHolder -> holder.bindView(item as VCardWrapper)
is VCardPropertyViewHolder -> holder.bindView(item as VCardPropertyWrapper)
}
}
inner class VCardContactViewHolder(val binding: ItemVcardContactBinding) : RecyclerView.ViewHolder(binding.root) {
fun bindView(item: VCardWrapper) {
val name = item.fullName
binding.apply {
itemContactName.apply {
text = name
setTextColor(textColor)
setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize * 1.1f)
}
itemContactImage.apply {
val photo = item.vCard.photos.firstOrNull()
val placeholder = if (name != null) {
SimpleContactsHelper(context).getContactLetterIcon(name).toDrawable(resources)
} else {
null
}
val roundingRadius = resources.getDimensionPixelSize(org.fossify.commons.R.dimen.big_margin)
val transformation = RoundedCorners(roundingRadius)
val options = RequestOptions()
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.placeholder(placeholder)
.transform(transformation)
Glide.with(this)
.load(photo?.data ?: photo?.url)
.apply(options)
.transition(DrawableTransitionOptions.withCrossFade())
.into(this)
}
expandCollapseIcon.apply {
val expandCollapseDrawable = if (item.expanded) {
R.drawable.ic_collapse_up
} else {
R.drawable.ic_expand_down
}
setImageResource(expandCollapseDrawable)
applyColorFilter(textColor)
}
if (items.size > 1) {
root.setOnClickListener {
expandOrCollapseRow(item)
}
}
root.onGlobalLayout {
if (items.size == 1) {
expandOrCollapseRow(item)
expandCollapseIcon.beGone()
}
}
}
}
private fun expandOrCollapseRow(item: VCardWrapper) {
val properties = item.properties
if (item.expanded) {
collapseRow(properties, item)
} else {
expandRow(properties, item)
}
}
private fun expandRow(properties: List<VCardPropertyWrapper>, vCardWrapper: VCardWrapper) {
vCardWrapper.expanded = true
val nextPosition = items.indexOf(vCardWrapper) + 1
items.addAll(nextPosition, properties)
notifyItemRangeInserted(nextPosition, properties.size)
binding.expandCollapseIcon.setImageResource(R.drawable.ic_collapse_up)
}
private fun collapseRow(properties: List<VCardPropertyWrapper>, vCardWrapper: VCardWrapper) {
vCardWrapper.expanded = false
val nextPosition = items.indexOf(vCardWrapper) + 1
repeat(properties.size) {
items.removeAt(nextPosition)
}
notifyItemRangeRemoved(nextPosition, properties.size)
binding.expandCollapseIcon.setImageResource(R.drawable.ic_expand_down)
}
}
inner class VCardPropertyViewHolder(val binding: ItemVcardContactPropertyBinding) : RecyclerView.ViewHolder(binding.root) {
fun bindView(item: VCardPropertyWrapper) {
binding.apply {
itemVcardPropertyTitle.apply {
text = item.value
setTextColor(textColor)
setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize * 1.1f)
}
itemVcardPropertySubtitle.apply {
text = item.type
setTextColor(textColor)
}
root.setOnClickListener {
itemClick(item)
}
}
}
}
}

View file

@ -0,0 +1,127 @@
package org.fossify.messages.databases
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import org.fossify.messages.helpers.Converters
import org.fossify.messages.interfaces.AttachmentsDao
import org.fossify.messages.interfaces.ConversationsDao
import org.fossify.messages.interfaces.MessageAttachmentsDao
import org.fossify.messages.interfaces.MessagesDao
import org.fossify.messages.models.*
@Database(entities = [Conversation::class, Attachment::class, MessageAttachment::class, Message::class, RecycleBinMessage::class], version = 8)
@TypeConverters(Converters::class)
abstract class MessagesDatabase : RoomDatabase() {
abstract fun ConversationsDao(): ConversationsDao
abstract fun AttachmentsDao(): AttachmentsDao
abstract fun MessageAttachmentsDao(): MessageAttachmentsDao
abstract fun MessagesDao(): MessagesDao
companion object {
private var db: MessagesDatabase? = null
fun getInstance(context: Context): MessagesDatabase {
if (db == null) {
synchronized(MessagesDatabase::class) {
if (db == null) {
db = Room.databaseBuilder(context.applicationContext, MessagesDatabase::class.java, "conversations.db")
.fallbackToDestructiveMigration()
.addMigrations(MIGRATION_1_2)
.addMigrations(MIGRATION_2_3)
.addMigrations(MIGRATION_3_4)
.addMigrations(MIGRATION_4_5)
.addMigrations(MIGRATION_5_6)
.addMigrations(MIGRATION_6_7)
.addMigrations(MIGRATION_7_8)
.build()
}
}
}
return db!!
}
private val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
database.apply {
execSQL("CREATE TABLE IF NOT EXISTS `messages` (`id` INTEGER PRIMARY KEY NOT NULL, `body` TEXT NOT NULL, `type` INTEGER NOT NULL, `participants` TEXT NOT NULL, `date` INTEGER NOT NULL, `read` INTEGER NOT NULL, `thread_id` INTEGER NOT NULL, `is_mms` INTEGER NOT NULL, `attachment` TEXT, `sender_name` TEXT NOT NULL, `sender_photo_uri` TEXT NOT NULL, `subscription_id` INTEGER NOT NULL)")
execSQL("CREATE TABLE IF NOT EXISTS `message_attachments` (`id` INTEGER PRIMARY KEY NOT NULL, `text` TEXT NOT NULL, `attachments` TEXT NOT NULL)")
execSQL("CREATE TABLE IF NOT EXISTS `attachments` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `message_id` INTEGER NOT NULL, `uri_string` TEXT NOT NULL, `mimetype` TEXT NOT NULL, `width` INTEGER NOT NULL, `height` INTEGER NOT NULL, `filename` TEXT NOT NULL)")
execSQL("CREATE UNIQUE INDEX `index_attachments_message_id` ON `attachments` (`message_id`)")
}
}
}
private val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(database: SupportSQLiteDatabase) {
database.apply {
execSQL("CREATE TABLE conversations_new (`thread_id` INTEGER NOT NULL PRIMARY KEY, `snippet` TEXT NOT NULL, `date` INTEGER NOT NULL, `read` INTEGER NOT NULL, `title` TEXT NOT NULL, `photo_uri` TEXT NOT NULL, `is_group_conversation` INTEGER NOT NULL, `phone_number` TEXT NOT NULL)")
execSQL(
"INSERT OR IGNORE INTO conversations_new (thread_id, snippet, date, read, title, photo_uri, is_group_conversation, phone_number) " +
"SELECT thread_id, snippet, date, read, title, photo_uri, is_group_conversation, phone_number FROM conversations"
)
execSQL("DROP TABLE conversations")
execSQL("ALTER TABLE conversations_new RENAME TO conversations")
execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_conversations_id` ON `conversations` (`thread_id`)")
}
}
}
private val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(database: SupportSQLiteDatabase) {
database.apply {
execSQL("ALTER TABLE messages ADD COLUMN status INTEGER NOT NULL DEFAULT -1")
}
}
}
private val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(database: SupportSQLiteDatabase) {
database.apply {
execSQL("ALTER TABLE messages ADD COLUMN is_scheduled INTEGER NOT NULL DEFAULT 0")
execSQL("ALTER TABLE conversations ADD COLUMN is_scheduled INTEGER NOT NULL DEFAULT 0")
}
}
}
private val MIGRATION_5_6 = object : Migration(5, 6) {
override fun migrate(database: SupportSQLiteDatabase) {
database.apply {
execSQL("ALTER TABLE conversations ADD COLUMN uses_custom_title INTEGER NOT NULL DEFAULT 0")
}
}
}
private val MIGRATION_6_7 = object : Migration(6, 7) {
override fun migrate(database: SupportSQLiteDatabase) {
database.apply {
execSQL("ALTER TABLE messages ADD COLUMN sender_phone_number TEXT NOT NULL DEFAULT ''")
}
}
}
private val MIGRATION_7_8 = object : Migration(7, 8) {
override fun migrate(database: SupportSQLiteDatabase) {
database.apply {
execSQL("ALTER TABLE conversations ADD COLUMN archived INTEGER NOT NULL DEFAULT 0")
execSQL("CREATE TABLE IF NOT EXISTS `recycle_bin_messages` (`id` INTEGER NOT NULL PRIMARY KEY, `deleted_ts` INTEGER NOT NULL)")
execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_recycle_bin_messages_id` ON `recycle_bin_messages` (`id`)")
}
}
}
}
}

View file

@ -0,0 +1,42 @@
package org.fossify.messages.dialogs
import androidx.appcompat.app.AlertDialog
import org.fossify.commons.activities.BaseSimpleActivity
import org.fossify.commons.extensions.getAlertDialogBuilder
import org.fossify.commons.extensions.setupDialogStuff
import org.fossify.commons.extensions.showKeyboard
import org.fossify.commons.extensions.value
import org.fossify.messages.databinding.DialogAddBlockedKeywordBinding
import org.fossify.messages.extensions.config
class AddBlockedKeywordDialog(val activity: BaseSimpleActivity, private val originalKeyword: String? = null, val callback: () -> Unit) {
init {
val binding = DialogAddBlockedKeywordBinding.inflate(activity.layoutInflater).apply {
if (originalKeyword != null) {
addBlockedKeywordEdittext.setText(originalKeyword)
}
}
activity.getAlertDialogBuilder()
.setPositiveButton(org.fossify.commons.R.string.ok, null)
.setNegativeButton(org.fossify.commons.R.string.cancel, null)
.apply {
activity.setupDialogStuff(binding.root, this) { alertDialog ->
alertDialog.showKeyboard(binding.addBlockedKeywordEdittext)
alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
val newBlockedKeyword = binding.addBlockedKeywordEdittext.value
if (originalKeyword != null && newBlockedKeyword != originalKeyword) {
activity.config.removeBlockedKeyword(originalKeyword)
}
if (newBlockedKeyword.isNotEmpty()) {
activity.config.addBlockedKeyword(newBlockedKeyword)
}
callback()
alertDialog.dismiss()
}
}
}
}
}

View file

@ -0,0 +1,37 @@
package org.fossify.messages.dialogs
import android.app.Activity
import androidx.appcompat.app.AlertDialog
import org.fossify.commons.extensions.beGoneIf
import org.fossify.commons.extensions.getAlertDialogBuilder
import org.fossify.commons.extensions.setupDialogStuff
import org.fossify.messages.databinding.DialogDeleteConfirmationBinding
class DeleteConfirmationDialog(
private val activity: Activity,
private val message: String,
private val showSkipRecycleBinOption: Boolean,
private val callback: (skipRecycleBin: Boolean) -> Unit
) {
private var dialog: AlertDialog? = null
val binding = DialogDeleteConfirmationBinding.inflate(activity.layoutInflater)
init {
binding.deleteRememberTitle.text = message
binding.skipTheRecycleBinCheckbox.beGoneIf(!showSkipRecycleBinOption)
activity.getAlertDialogBuilder()
.setPositiveButton(org.fossify.commons.R.string.yes) { _, _ -> dialogConfirmed() }
.setNegativeButton(org.fossify.commons.R.string.no, null)
.apply {
activity.setupDialogStuff(binding.root, this) { alertDialog ->
dialog = alertDialog
}
}
}
private fun dialogConfirmed() {
dialog?.dismiss()
callback(binding.skipTheRecycleBinCheckbox.isChecked)
}
}

View file

@ -0,0 +1,47 @@
package org.fossify.messages.dialogs
import androidx.appcompat.app.AlertDialog
import org.fossify.commons.extensions.*
import org.fossify.messages.R
import org.fossify.messages.activities.SimpleActivity
import org.fossify.messages.databinding.DialogExportMessagesBinding
import org.fossify.messages.extensions.config
class ExportMessagesDialog(
private val activity: SimpleActivity,
private val callback: (fileName: String) -> Unit,
) {
private val config = activity.config
init {
val binding = DialogExportMessagesBinding.inflate(activity.layoutInflater).apply {
exportSmsCheckbox.isChecked = config.exportSms
exportMmsCheckbox.isChecked = config.exportMms
exportMessagesFilename.setText(
activity.getString(R.string.messages) + "_" + activity.getCurrentFormattedDateTime()
)
}
activity.getAlertDialogBuilder()
.setPositiveButton(org.fossify.commons.R.string.ok, null)
.setNegativeButton(org.fossify.commons.R.string.cancel, null)
.apply {
activity.setupDialogStuff(binding.root, this, R.string.export_messages) { alertDialog ->
alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
config.exportSms = binding.exportSmsCheckbox.isChecked
config.exportMms = binding.exportMmsCheckbox.isChecked
val filename = binding.exportMessagesFilename.value
when {
filename.isEmpty() -> activity.toast(org.fossify.commons.R.string.empty_name)
filename.isAValidFilename() -> {
callback(filename)
alertDialog.dismiss()
}
else -> activity.toast(org.fossify.commons.R.string.invalid_name)
}
}
}
}
}
}

View file

@ -0,0 +1,70 @@
package org.fossify.messages.dialogs
import androidx.appcompat.app.AlertDialog
import org.fossify.commons.extensions.getAlertDialogBuilder
import org.fossify.commons.extensions.setupDialogStuff
import org.fossify.commons.extensions.toast
import org.fossify.commons.helpers.ensureBackgroundThread
import org.fossify.messages.R
import org.fossify.messages.activities.SimpleActivity
import org.fossify.messages.databinding.DialogImportMessagesBinding
import org.fossify.messages.extensions.config
import org.fossify.messages.helpers.MessagesImporter
import org.fossify.messages.models.ImportResult
import org.fossify.messages.models.MessagesBackup
class ImportMessagesDialog(
private val activity: SimpleActivity,
private val messages: List<MessagesBackup>,
) {
private val config = activity.config
init {
var ignoreClicks = false
val binding = DialogImportMessagesBinding.inflate(activity.layoutInflater).apply {
importSmsCheckbox.isChecked = config.importSms
importMmsCheckbox.isChecked = config.importMms
}
activity.getAlertDialogBuilder()
.setPositiveButton(org.fossify.commons.R.string.ok, null)
.setNegativeButton(org.fossify.commons.R.string.cancel, null)
.apply {
activity.setupDialogStuff(binding.root, this, R.string.import_messages) { alertDialog ->
alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
if (ignoreClicks) {
return@setOnClickListener
}
if (!binding.importSmsCheckbox.isChecked && !binding.importMmsCheckbox.isChecked) {
activity.toast(R.string.no_option_selected)
return@setOnClickListener
}
ignoreClicks = true
activity.toast(org.fossify.commons.R.string.importing)
config.importSms = binding.importSmsCheckbox.isChecked
config.importMms = binding.importMmsCheckbox.isChecked
ensureBackgroundThread {
MessagesImporter(activity).restoreMessages(messages) {
handleParseResult(it)
alertDialog.dismiss()
}
}
}
}
}
}
private fun handleParseResult(result: ImportResult) {
activity.toast(
when (result) {
ImportResult.IMPORT_OK -> org.fossify.commons.R.string.importing_successful
ImportResult.IMPORT_PARTIAL -> org.fossify.commons.R.string.importing_some_entries_failed
ImportResult.IMPORT_FAIL -> org.fossify.commons.R.string.importing_failed
else -> org.fossify.commons.R.string.no_items_found
}
)
}
}

View file

@ -0,0 +1,20 @@
package org.fossify.messages.dialogs
import org.fossify.commons.activities.BaseSimpleActivity
import org.fossify.commons.extensions.getAlertDialogBuilder
import org.fossify.commons.extensions.setupDialogStuff
import org.fossify.messages.databinding.DialogInvalidNumberBinding
class InvalidNumberDialog(val activity: BaseSimpleActivity, val text: String) {
init {
val binding = DialogInvalidNumberBinding.inflate(activity.layoutInflater).apply {
dialogInvalidNumberDesc.text = text
}
activity.getAlertDialogBuilder()
.setPositiveButton(org.fossify.commons.R.string.ok) { _, _ -> { } }
.apply {
activity.setupDialogStuff(binding.root, this)
}
}
}

View file

@ -0,0 +1,148 @@
package org.fossify.messages.dialogs
import android.view.*
import android.widget.PopupMenu
import org.fossify.commons.activities.BaseSimpleActivity
import org.fossify.commons.adapters.MyRecyclerViewAdapter
import org.fossify.commons.extensions.copyToClipboard
import org.fossify.commons.extensions.getPopupMenuTheme
import org.fossify.commons.extensions.getProperTextColor
import org.fossify.commons.extensions.setupViewBackground
import org.fossify.commons.interfaces.RefreshRecyclerViewListener
import org.fossify.commons.views.MyRecyclerView
import org.fossify.messages.R
import org.fossify.messages.databinding.ItemManageBlockedKeywordBinding
import org.fossify.messages.extensions.config
class ManageBlockedKeywordsAdapter(
activity: BaseSimpleActivity, var blockedKeywords: ArrayList<String>, val listener: RefreshRecyclerViewListener?,
recyclerView: MyRecyclerView, itemClick: (Any) -> Unit
) : MyRecyclerViewAdapter(activity, recyclerView, itemClick) {
init {
setupDragListener(true)
}
override fun getActionMenuId() = R.menu.cab_blocked_keywords
override fun prepareActionMode(menu: Menu) {
menu.apply {
findItem(R.id.cab_copy_keyword).isVisible = isOneItemSelected()
}
}
override fun actionItemPressed(id: Int) {
if (selectedKeys.isEmpty()) {
return
}
when (id) {
R.id.cab_copy_keyword -> copyKeywordToClipboard()
R.id.cab_delete -> deleteSelection()
}
}
override fun getSelectableItemCount() = blockedKeywords.size
override fun getIsItemSelectable(position: Int) = true
override fun getItemSelectionKey(position: Int) = blockedKeywords.getOrNull(position)?.hashCode()
override fun getItemKeyPosition(key: Int) = blockedKeywords.indexOfFirst { it.hashCode() == key }
override fun onActionModeCreated() {}
override fun onActionModeDestroyed() {}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = ItemManageBlockedKeywordBinding.inflate(layoutInflater, parent, false)
return createViewHolder(binding.root)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val blockedKeyword = blockedKeywords[position]
holder.bindView(blockedKeyword, allowSingleClick = true, allowLongClick = true) { itemView, _ ->
setupView(itemView, blockedKeyword)
}
bindViewHolder(holder)
}
override fun getItemCount() = blockedKeywords.size
private fun getSelectedItems() = blockedKeywords.filter { selectedKeys.contains(it.hashCode()) }
private fun setupView(view: View, blockedKeyword: String) {
ItemManageBlockedKeywordBinding.bind(view).apply {
root.setupViewBackground(activity)
manageBlockedKeywordHolder.isSelected = selectedKeys.contains(blockedKeyword.hashCode())
manageBlockedKeywordTitle.apply {
text = blockedKeyword
setTextColor(textColor)
}
overflowMenuIcon.drawable.apply {
mutate()
setTint(activity.getProperTextColor())
}
overflowMenuIcon.setOnClickListener {
showPopupMenu(overflowMenuAnchor, blockedKeyword)
}
}
}
private fun showPopupMenu(view: View, blockedKeyword: String) {
finishActMode()
val theme = activity.getPopupMenuTheme()
val contextTheme = ContextThemeWrapper(activity, theme)
PopupMenu(contextTheme, view, Gravity.END).apply {
inflate(getActionMenuId())
setOnMenuItemClickListener { item ->
val blockedKeywordId = blockedKeyword.hashCode()
when (item.itemId) {
R.id.cab_copy_keyword -> {
executeItemMenuOperation(blockedKeywordId) {
copyKeywordToClipboard()
}
}
R.id.cab_delete -> {
executeItemMenuOperation(blockedKeywordId) {
deleteSelection()
}
}
}
true
}
show()
}
}
private fun executeItemMenuOperation(blockedKeywordId: Int, callback: () -> Unit) {
selectedKeys.add(blockedKeywordId)
callback()
selectedKeys.remove(blockedKeywordId)
}
private fun copyKeywordToClipboard() {
val selectedKeyword = getSelectedItems().firstOrNull() ?: return
activity.copyToClipboard(selectedKeyword)
finishActMode()
}
private fun deleteSelection() {
val deleteBlockedKeywords = HashSet<String>(selectedKeys.size)
val positions = getSelectedItemPositions()
getSelectedItems().forEach {
deleteBlockedKeywords.add(it)
activity.config.removeBlockedKeyword(it)
}
blockedKeywords.removeAll(deleteBlockedKeywords)
removeSelectedItems(positions)
if (blockedKeywords.isEmpty()) {
listener?.refreshItems()
}
}
}

View file

@ -0,0 +1,76 @@
package org.fossify.messages.dialogs
import android.annotation.SuppressLint
import android.telephony.SubscriptionInfo
import org.fossify.commons.activities.BaseSimpleActivity
import org.fossify.commons.dialogs.BasePropertiesDialog
import org.fossify.commons.extensions.getAlertDialogBuilder
import org.fossify.commons.extensions.getTimeFormat
import org.fossify.commons.extensions.setupDialogStuff
import org.fossify.messages.R
import org.fossify.messages.extensions.config
import org.fossify.messages.extensions.subscriptionManagerCompat
import org.fossify.messages.models.Message
import org.joda.time.DateTime
class MessageDetailsDialog(val activity: BaseSimpleActivity, val message: Message) : BasePropertiesDialog(activity) {
init {
@SuppressLint("MissingPermission")
val availableSIMs = activity.subscriptionManagerCompat().activeSubscriptionInfoList
addProperty(message.getSenderOrReceiverLabel(), message.getSenderOrReceiverPhoneNumbers())
if (availableSIMs.count() > 1) {
addProperty(R.string.message_details_sim, message.getSIM(availableSIMs))
}
addProperty(message.getSentOrReceivedAtLabel(), message.getSentOrReceivedAt())
activity.getAlertDialogBuilder()
.setPositiveButton(org.fossify.commons.R.string.ok) { _, _ -> }
.apply {
activity.setupDialogStuff(mDialogView.root, this, R.string.message_details)
}
}
private fun Message.getSenderOrReceiverLabel(): Int {
return if (isReceivedMessage()) {
R.string.message_details_sender
} else {
R.string.message_details_receiver
}
}
private fun Message.getSenderOrReceiverPhoneNumbers(): String {
return if (isReceivedMessage()) {
formatContactInfo(senderName, senderPhoneNumber)
} else {
participants.joinToString(", ") {
formatContactInfo(it.name, it.phoneNumbers.first().value)
}
}
}
private fun formatContactInfo(name: String, phoneNumber: String): String {
return if (name != phoneNumber) {
"$name ($phoneNumber)"
} else {
phoneNumber
}
}
private fun Message.getSIM(availableSIMs: List<SubscriptionInfo>): String {
return availableSIMs.firstOrNull { it.subscriptionId == subscriptionId }?.displayName?.toString()
?: activity.getString(org.fossify.commons.R.string.unknown)
}
private fun Message.getSentOrReceivedAtLabel(): Int {
return if (isReceivedMessage()) {
R.string.message_details_received_at
} else {
R.string.message_details_sent_at
}
}
private fun Message.getSentOrReceivedAt(): String {
return DateTime(date * 1000L).toString("${activity.config.dateFormat} ${activity.getTimeFormat()}")
}
}

View file

@ -0,0 +1,54 @@
package org.fossify.messages.dialogs
import android.app.Activity
import android.content.DialogInterface.BUTTON_POSITIVE
import androidx.appcompat.app.AlertDialog
import org.fossify.commons.extensions.getAlertDialogBuilder
import org.fossify.commons.extensions.setupDialogStuff
import org.fossify.commons.extensions.showKeyboard
import org.fossify.commons.extensions.toast
import org.fossify.messages.R
import org.fossify.messages.databinding.DialogRenameConversationBinding
import org.fossify.messages.models.Conversation
class RenameConversationDialog(
private val activity: Activity,
private val conversation: Conversation,
private val callback: (name: String) -> Unit,
) {
private var dialog: AlertDialog? = null
init {
val binding = DialogRenameConversationBinding.inflate(activity.layoutInflater).apply {
renameConvEditText.apply {
if (conversation.usesCustomTitle) {
setText(conversation.title)
}
hint = conversation.title
}
}
activity.getAlertDialogBuilder()
.setPositiveButton(org.fossify.commons.R.string.ok, null)
.setNegativeButton(org.fossify.commons.R.string.cancel, null)
.apply {
activity.setupDialogStuff(binding.root, this, R.string.rename_conversation) { alertDialog ->
dialog = alertDialog
alertDialog.showKeyboard(binding.renameConvEditText)
alertDialog.getButton(BUTTON_POSITIVE).apply {
setOnClickListener {
val newTitle = binding.renameConvEditText.text.toString()
if (newTitle.isEmpty()) {
activity.toast(org.fossify.commons.R.string.empty_name)
return@setOnClickListener
}
callback(newTitle)
alertDialog.dismiss()
}
}
}
}
}
}

View file

@ -0,0 +1,193 @@
package org.fossify.messages.dialogs
import android.app.DatePickerDialog
import android.app.DatePickerDialog.OnDateSetListener
import android.app.TimePickerDialog
import android.app.TimePickerDialog.OnTimeSetListener
import android.text.format.DateFormat
import androidx.appcompat.app.AlertDialog
import com.google.android.material.timepicker.MaterialTimePicker
import com.google.android.material.timepicker.TimeFormat
import org.fossify.commons.activities.BaseSimpleActivity
import org.fossify.commons.extensions.*
import org.fossify.messages.R
import org.fossify.messages.databinding.ScheduleMessageDialogBinding
import org.fossify.messages.extensions.config
import org.fossify.messages.extensions.roundToClosestMultipleOf
import org.joda.time.DateTime
import java.util.Calendar
class ScheduleMessageDialog(
private val activity: BaseSimpleActivity,
private var dateTime: DateTime? = null,
private val callback: (dateTime: DateTime?) -> Unit
) {
private val binding = ScheduleMessageDialogBinding.inflate(activity.layoutInflater)
private val textColor = activity.getProperTextColor()
private var previewDialog: AlertDialog? = null
private var previewShown = false
private var isNewMessage = dateTime == null
private val calendar = Calendar.getInstance()
init {
arrayOf(binding.subtitle, binding.editTime, binding.editDate).forEach {
it.setTextColor(textColor)
}
arrayOf(binding.dateImage, binding.timeImage).forEach {
it.applyColorFilter(textColor)
}
binding.editDate.setOnClickListener { showDatePicker() }
binding.editTime.setOnClickListener { showTimePicker() }
val targetDateTime = dateTime ?: DateTime.now().plusHours(1)
updateTexts(targetDateTime)
if (isNewMessage) {
showDatePicker()
} else {
showPreview()
}
}
private fun updateTexts(dateTime: DateTime) {
val dateFormat = activity.config.dateFormat
val timeFormat = activity.getTimeFormat()
binding.editDate.text = dateTime.toString(dateFormat)
binding.editTime.text = dateTime.toString(timeFormat)
}
private fun showPreview() {
if (previewShown) {
return
}
activity.getAlertDialogBuilder()
.setPositiveButton(org.fossify.commons.R.string.ok, null)
.setNegativeButton(org.fossify.commons.R.string.cancel, null)
.apply {
previewShown = true
activity.setupDialogStuff(binding.root, this, R.string.schedule_message) { dialog ->
previewDialog = dialog
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
if (validateDateTime()) {
callback(dateTime)
dialog.dismiss()
}
}
dialog.setOnDismissListener {
previewShown = false
previewDialog = null
}
}
}
}
private fun showDatePicker() {
val year = dateTime?.year ?: calendar.get(Calendar.YEAR)
val monthOfYear = dateTime?.monthOfYear?.minus(1) ?: calendar.get(Calendar.MONTH)
val dayOfMonth = dateTime?.dayOfMonth ?: calendar.get(Calendar.DAY_OF_MONTH)
val dateSetListener = OnDateSetListener { _, y, m, d -> dateSet(y, m, d) }
DatePickerDialog(
activity, activity.getDatePickerDialogTheme(), dateSetListener, year, monthOfYear, dayOfMonth
).apply {
datePicker.minDate = System.currentTimeMillis()
show()
getButton(AlertDialog.BUTTON_NEGATIVE).apply {
text = activity.getString(org.fossify.commons.R.string.cancel)
setOnClickListener {
dismiss()
}
}
}
}
private fun showTimePicker() {
val hourOfDay = dateTime?.hourOfDay ?: getNextHour()
val minute = dateTime?.minuteOfHour ?: getNextMinute()
if (activity.config.isUsingSystemTheme) {
val timeFormat = if (DateFormat.is24HourFormat(activity)) {
TimeFormat.CLOCK_24H
} else {
TimeFormat.CLOCK_12H
}
val timePicker = MaterialTimePicker.Builder()
.setTimeFormat(timeFormat)
.setHour(hourOfDay)
.setMinute(minute)
.build()
timePicker.addOnPositiveButtonClickListener {
timeSet(timePicker.hour, timePicker.minute)
}
timePicker.show(activity.supportFragmentManager, "")
} else {
val timeSetListener = OnTimeSetListener { _, hours, minutes -> timeSet(hours, minutes) }
TimePickerDialog(
activity, activity.getDatePickerDialogTheme(), timeSetListener, hourOfDay, minute, DateFormat.is24HourFormat(activity)
).apply {
show()
getButton(AlertDialog.BUTTON_NEGATIVE).apply {
text = activity.getString(org.fossify.commons.R.string.cancel)
setOnClickListener {
dismiss()
}
}
}
}
}
private fun dateSet(year: Int, monthOfYear: Int, dayOfMonth: Int) {
if (isNewMessage) {
showTimePicker()
}
dateTime = DateTime.now()
.withDate(year, monthOfYear + 1, dayOfMonth)
.run {
if (dateTime != null) {
withTime(dateTime!!.hourOfDay, dateTime!!.minuteOfHour, 0, 0)
} else {
withTime(getNextHour(), getNextMinute(), 0, 0)
}
}
if (!isNewMessage) {
validateDateTime()
}
isNewMessage = false
updateTexts(dateTime!!)
}
private fun timeSet(hourOfDay: Int, minute: Int) {
dateTime = dateTime?.withHourOfDay(hourOfDay)?.withMinuteOfHour(minute)
if (validateDateTime()) {
updateTexts(dateTime!!)
showPreview()
} else {
showTimePicker()
}
}
private fun validateDateTime(): Boolean {
return if (dateTime?.isAfterNow == false) {
activity.toast(R.string.must_pick_time_in_the_future)
false
} else {
true
}
}
private fun getNextHour() = (calendar.get(Calendar.HOUR_OF_DAY) + 1).coerceIn(0, 23)
private fun getNextMinute() = (calendar.get(Calendar.MINUTE) + 5).roundToClosestMultipleOf(5).coerceIn(0, 59)
}

View file

@ -0,0 +1,21 @@
package org.fossify.messages.dialogs
import org.fossify.commons.activities.BaseSimpleActivity
import org.fossify.commons.extensions.getAlertDialogBuilder
import org.fossify.commons.extensions.setupDialogStuff
import org.fossify.messages.databinding.DialogSelectTextBinding
// helper dialog for selecting just a part of a message, not copying the whole into clipboard
class SelectTextDialog(val activity: BaseSimpleActivity, val text: String) {
init {
val binding = DialogSelectTextBinding.inflate(activity.layoutInflater).apply {
dialogSelectTextValue.text = text
}
activity.getAlertDialogBuilder()
.setPositiveButton(org.fossify.commons.R.string.ok) { _, _ -> { } }
.apply {
activity.setupDialogStuff(binding.root, this)
}
}
}

View file

@ -0,0 +1,77 @@
package org.fossify.messages.extensions
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.provider.ContactsContract
import org.fossify.commons.extensions.*
import org.fossify.commons.helpers.CONTACT_ID
import org.fossify.commons.helpers.IS_PRIVATE
import org.fossify.commons.helpers.SimpleContactsHelper
import org.fossify.commons.helpers.ensureBackgroundThread
import org.fossify.commons.models.SimpleContact
import java.util.Locale
fun Activity.dialNumber(phoneNumber: String, callback: (() -> Unit)? = null) {
hideKeyboard()
Intent(Intent.ACTION_DIAL).apply {
data = Uri.fromParts("tel", phoneNumber, null)
try {
startActivity(this)
callback?.invoke()
} catch (e: ActivityNotFoundException) {
toast(org.fossify.commons.R.string.no_app_found)
} catch (e: Exception) {
showErrorToast(e)
}
}
}
fun Activity.launchViewIntent(uri: Uri, mimetype: String, filename: String) {
Intent().apply {
action = Intent.ACTION_VIEW
setDataAndType(uri, mimetype.lowercase(Locale.getDefault()))
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
try {
hideKeyboard()
startActivity(this)
} catch (e: ActivityNotFoundException) {
val newMimetype = filename.getMimeType()
if (newMimetype.isNotEmpty() && mimetype != newMimetype) {
launchViewIntent(uri, newMimetype, filename)
} else {
toast(org.fossify.commons.R.string.no_app_found)
}
} catch (e: Exception) {
showErrorToast(e)
}
}
}
fun Activity.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)
}
}
}
}

View file

@ -0,0 +1,9 @@
package org.fossify.messages.extensions
import android.graphics.Bitmap
fun Bitmap.CompressFormat.extension() = when (this) {
Bitmap.CompressFormat.PNG -> "png"
Bitmap.CompressFormat.WEBP -> "webp"
else -> "jpg"
}

View file

@ -0,0 +1,34 @@
package org.fossify.messages.extensions
import android.content.ContentValues
inline fun <T> List<T>.indexOfFirstOrNull(predicate: (T) -> Boolean): Int? {
var index = 0
for (item in this) {
if (predicate(item))
return index
index++
}
return null
}
fun Map<String, Any>.toContentValues(): ContentValues {
val contentValues = ContentValues()
for (item in entries) {
when (val value = item.value) {
is String -> contentValues.put(item.key, value)
is Byte -> contentValues.put(item.key, value)
is Short -> contentValues.put(item.key, value)
is Int -> contentValues.put(item.key, value)
is Long -> contentValues.put(item.key, value)
is Float -> contentValues.put(item.key, value)
is Double -> contentValues.put(item.key, value)
is Boolean -> contentValues.put(item.key, value)
is ByteArray -> contentValues.put(item.key, value)
}
}
return contentValues
}
fun <T> Collection<T>.toArrayList() = ArrayList(this)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,20 @@
package org.fossify.messages.extensions
import android.database.Cursor
import com.google.gson.JsonNull
import com.google.gson.JsonObject
fun Cursor.rowsToJson(): JsonObject {
val obj = JsonObject()
for (i in 0 until columnCount) {
val key = getColumnName(i)
when (getType(i)) {
Cursor.FIELD_TYPE_INTEGER -> obj.addProperty(key, getLong(i))
Cursor.FIELD_TYPE_FLOAT -> obj.addProperty(key, getFloat(i))
Cursor.FIELD_TYPE_STRING -> obj.addProperty(key, getString(i))
Cursor.FIELD_TYPE_NULL -> obj.add(key, JsonNull.INSTANCE)
}
}
return obj
}

View file

@ -0,0 +1,8 @@
package org.fossify.messages.extensions
import android.text.format.DateFormat
import java.util.Date
fun Date.format(pattern: String): String {
return DateFormat.format(pattern, this).toString()
}

View file

@ -0,0 +1,8 @@
package org.fossify.messages.extensions
import kotlin.math.roundToInt
/**
* Returns the closest number divisible by [multipleOf].
*/
fun Int.roundToClosestMultipleOf(multipleOf: Int = 1) = (toDouble() / multipleOf).roundToInt() * multipleOf

View file

@ -0,0 +1,8 @@
package org.fossify.messages.extensions
import android.text.TextUtils
import org.fossify.commons.models.SimpleContact
fun ArrayList<SimpleContact>.getThreadTitle(): String = TextUtils.join(", ", map { it.name }.toTypedArray()).orEmpty()
fun ArrayList<SimpleContact>.getAddresses() = flatMap { it.phoneNumbers }.map { it.normalizedNumber }

View file

@ -0,0 +1,49 @@
package org.fossify.messages.extensions
fun String.getExtensionFromMimeType(): String {
return when (lowercase()) {
"image/png" -> ".png"
"image/apng" -> ".apng"
"image/webp" -> ".webp"
"image/svg+xml" -> ".svg"
"image/gif" -> ".gif"
else -> ".jpg"
}
}
fun String.isImageMimeType(): Boolean {
return lowercase().startsWith("image")
}
fun String.isGifMimeType(): Boolean {
return lowercase().endsWith("gif")
}
fun String.isVideoMimeType(): Boolean {
return lowercase().startsWith("video")
}
fun String.isVCardMimeType(): Boolean {
val lowercase = lowercase()
return lowercase.endsWith("x-vcard") || lowercase.endsWith("vcard")
}
fun String.isAudioMimeType(): Boolean {
return lowercase().startsWith("audio")
}
fun String.isCalendarMimeType(): Boolean {
return lowercase().endsWith("calendar")
}
fun String.isPdfMimeType(): Boolean {
return lowercase().endsWith("pdf")
}
fun String.isZipMimeType(): Boolean {
return lowercase().endsWith("zip")
}
fun String.isPlainTextMimeType(): Boolean {
return lowercase() == "text/plain"
}

View file

@ -0,0 +1,18 @@
package org.fossify.messages.extensions
import android.animation.ObjectAnimator
import android.view.View
import androidx.core.animation.doOnStart
import androidx.core.view.isVisible
fun View.showWithAnimation(duration: Long = 250L) {
if (!isVisible) {
ObjectAnimator.ofFloat(
this, "alpha", 0f, 1f
).apply {
this.duration = duration
doOnStart { visibility = View.VISIBLE }
}.start()
}
}

View file

@ -0,0 +1,9 @@
package org.fossify.messages.extensions.gson
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.reflect.TypeToken
private val gsonBuilder = GsonBuilder().registerTypeAdapter(object : TypeToken<Map<String, Any>>() {}.type, MapDeserializerDoubleAsIntFix())
val gson: Gson = gsonBuilder.create()

View file

@ -0,0 +1,59 @@
package org.fossify.messages.extensions.gson
import com.google.gson.*
import java.math.BigDecimal
import java.math.BigInteger
val JsonElement.optString: String?
get() = safeConversion { asString }
val JsonElement.optLong: Long?
get() = safeConversion { asLong }
val JsonElement.optBoolean: Boolean?
get() = safeConversion { asBoolean }
val JsonElement.optFloat: Float?
get() = safeConversion { asFloat }
val JsonElement.optDouble: Double?
get() = safeConversion { asDouble }
val JsonElement.optJsonObject: JsonObject?
get() = safeConversion { asJsonObject }
val JsonElement.optJsonArray: JsonArray?
get() = safeConversion { asJsonArray }
val JsonElement.optJsonPrimitive: JsonPrimitive?
get() = safeConversion { asJsonPrimitive }
val JsonElement.optInt: Int?
get() = safeConversion { asInt }
val JsonElement.optBigDecimal: BigDecimal?
get() = safeConversion { asBigDecimal }
val JsonElement.optBigInteger: BigInteger?
get() = safeConversion { asBigInteger }
val JsonElement.optByte: Byte?
get() = safeConversion { asByte }
val JsonElement.optShort: Short?
get() = safeConversion { asShort }
val JsonElement.optJsonNull: JsonNull?
get() = safeConversion { asJsonNull }
val JsonElement.optCharacter: Char?
get() = safeConversion { asCharacter }
private fun <T> JsonElement.safeConversion(converter: () -> T?): T? {
return try {
converter()
} catch (e: Exception) {
null
}
}

View file

@ -0,0 +1,45 @@
package org.fossify.messages.extensions.gson
import com.google.gson.JsonArray
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.google.gson.JsonPrimitive
fun JsonObject.optGet(key: String): JsonElement? = get(key)
fun JsonObject.optGetJsonArray(key: String): JsonArray? = getAsJsonArray(key)
fun JsonObject.optGetJsonObject(key: String): JsonObject? = getAsJsonObject(key)
fun JsonObject.optGetJsonPrimitive(key: String): JsonPrimitive? = getAsJsonPrimitive(key)
fun JsonObject.optString(key: String) = optGet(key)?.asString
fun JsonObject.optLong(key: String) = optGet(key)?.asLong
fun JsonObject.optBoolean(key: String) = optGet(key)?.asBoolean
fun JsonObject.optFloat(key: String) = optGet(key)?.asFloat
fun JsonObject.optDouble(key: String) = optGet(key)?.asDouble
fun JsonObject.optJsonObject(key: String) = optGet(key)?.asJsonObject
fun JsonObject.optJsonArray(key: String) = optGet(key)?.asJsonArray
fun JsonObject.optJsonPrimitive(key: String) = optGet(key)?.asJsonPrimitive
fun JsonObject.optInt(key: String) = optGet(key)?.asInt
fun JsonObject.optBigDecimal(key: String) = optGet(key)?.asBigDecimal
fun JsonObject.optBigInteger(key: String) = optGet(key)?.asBigInteger
fun JsonObject.optByte(key: String) = optGet(key)?.asByte
fun JsonObject.optShort(key: String) = optGet(key)?.asShort
fun JsonObject.optJsonNull(key: String) = optGet(key)?.asJsonNull
fun JsonObject.optCharacter(key: String) = optGet(key)?.asCharacter

View file

@ -0,0 +1,62 @@
package org.fossify.messages.extensions.gson
import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
import com.google.gson.JsonParseException
import com.google.gson.internal.LinkedTreeMap
import java.lang.reflect.Type
import kotlin.math.ceil
// https://stackoverflow.com/a/36529534/10552591
class MapDeserializerDoubleAsIntFix : JsonDeserializer<Map<String, Any>?> {
@Throws(JsonParseException::class)
override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Map<String, Any>? {
return read(json) as Map<String, Any>?
}
fun read(element: JsonElement): Any? {
when {
element.isJsonArray -> {
val list: MutableList<Any?> = ArrayList()
val arr = element.asJsonArray
for (anArr in arr) {
list.add(read(anArr))
}
return list
}
element.isJsonObject -> {
val map: MutableMap<String, Any?> = LinkedTreeMap()
val obj = element.asJsonObject
val entitySet = obj.entrySet()
for ((key, value) in entitySet) {
map[key] = read(value)
}
return map
}
element.isJsonPrimitive -> {
val prim = element.asJsonPrimitive
when {
prim.isBoolean -> {
return prim.asBoolean
}
prim.isString -> {
return prim.asString
}
prim.isNumber -> {
val num = prim.asNumber
// here you can handle double int/long values
// and return any type you want
// this solution will transform 3.0 float to long values
return if (ceil(num.toDouble()) == num.toLong().toDouble()) num.toLong() else num.toDouble()
}
}
}
}
return null
}
}

View file

@ -0,0 +1,177 @@
package org.fossify.messages.helpers
import android.app.Activity
import android.net.Uri
import org.fossify.commons.extensions.*
import org.fossify.commons.helpers.SimpleContactsHelper
import org.fossify.commons.helpers.ensureBackgroundThread
import org.fossify.messages.R
import org.fossify.messages.databinding.ItemAttachmentDocumentBinding
import org.fossify.messages.databinding.ItemAttachmentDocumentPreviewBinding
import org.fossify.messages.databinding.ItemAttachmentVcardBinding
import org.fossify.messages.databinding.ItemAttachmentVcardPreviewBinding
import org.fossify.messages.extensions.*
fun ItemAttachmentDocumentPreviewBinding.setupDocumentPreview(
uri: Uri,
title: String,
mimeType: String,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
onRemoveButtonClicked: (() -> Unit)? = null
) {
documentAttachmentHolder.setupDocumentPreview(uri, title, mimeType, onClick, onLongClick)
removeAttachmentButtonHolder.removeAttachmentButton.apply {
beVisible()
background.applyColorFilter(context.getProperPrimaryColor())
if (onRemoveButtonClicked != null) {
setOnClickListener {
onRemoveButtonClicked.invoke()
}
}
}
}
fun ItemAttachmentDocumentBinding.setupDocumentPreview(
uri: Uri,
title: String,
mimeType: String,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null
) {
val context = root.context
if (title.isNotEmpty()) {
filename.text = title
}
ensureBackgroundThread {
try {
val size = context.getFileSizeFromUri(uri)
root.post {
fileSize.beVisible()
fileSize.text = size.formatSize()
}
} catch (e: Exception) {
root.post {
fileSize.beGone()
}
}
}
val textColor = context.getProperTextColor()
val primaryColor = context.getProperPrimaryColor()
filename.setTextColor(textColor)
fileSize.setTextColor(textColor)
icon.setImageResource(getIconResourceForMimeType(mimeType))
icon.background.setTint(primaryColor)
root.background.applyColorFilter(primaryColor.darkenColor())
root.setOnClickListener {
onClick?.invoke()
}
root.setOnLongClickListener {
onLongClick?.invoke()
true
}
}
fun ItemAttachmentVcardPreviewBinding.setupVCardPreview(
activity: Activity,
uri: Uri,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
onRemoveButtonClicked: (() -> Unit)? = null,
) {
vcardProgress.beVisible()
vcardAttachmentHolder.setupVCardPreview(activity = activity, uri = uri, attachment = true, onClick = onClick, onLongClick = onLongClick) {
vcardProgress.beGone()
removeAttachmentButtonHolder.removeAttachmentButton.apply {
beVisible()
background.applyColorFilter(activity.getProperPrimaryColor())
if (onRemoveButtonClicked != null) {
setOnClickListener {
onRemoveButtonClicked.invoke()
}
}
}
}
}
fun ItemAttachmentVcardBinding.setupVCardPreview(
activity: Activity,
uri: Uri,
attachment: Boolean = false,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
onVCardLoaded: (() -> Unit)? = null,
) {
val context = root.context
val textColor = activity.getProperTextColor()
val primaryColor = activity.getProperPrimaryColor()
root.background.applyColorFilter(primaryColor.darkenColor())
vcardTitle.setTextColor(textColor)
vcardSubtitle.setTextColor(textColor)
arrayOf(vcardPhoto, vcardTitle, vcardSubtitle, viewContactDetails).forEach {
it.beGone()
}
parseVCardFromUri(activity, uri) { vCards ->
activity.runOnUiThread {
if (vCards.isEmpty()) {
vcardTitle.beVisible()
vcardTitle.text = context.getString(org.fossify.commons.R.string.unknown_error_occurred)
return@runOnUiThread
}
val title = vCards.firstOrNull()?.parseNameFromVCard()
val imageIcon = if (title != null) {
SimpleContactsHelper(activity).getContactLetterIcon(title)
} else {
null
}
arrayOf(vcardPhoto, vcardTitle).forEach {
it.beVisible()
}
vcardPhoto.setImageBitmap(imageIcon)
vcardTitle.text = title
if (vCards.size > 1) {
vcardSubtitle.beVisible()
val quantity = vCards.size - 1
vcardSubtitle.text = context.resources.getQuantityString(R.plurals.and_other_contacts, quantity, quantity)
} else {
vcardSubtitle.beGone()
}
if (attachment) {
onVCardLoaded?.invoke()
} else {
viewContactDetails.setTextColor(primaryColor)
viewContactDetails.beVisible()
}
vcardAttachmentHolder.setOnClickListener {
onClick?.invoke()
}
vcardAttachmentHolder.setOnLongClickListener {
onLongClick?.invoke()
true
}
}
}
}
private fun getIconResourceForMimeType(mimeType: String) = when {
mimeType.isAudioMimeType() -> R.drawable.ic_vector_audio_file
mimeType.isCalendarMimeType() -> R.drawable.ic_calendar_month_vector
mimeType.isPdfMimeType() -> R.drawable.ic_vector_pdf
mimeType.isZipMimeType() -> R.drawable.ic_vector_folder_zip
else -> R.drawable.ic_document_vector
}

View file

@ -0,0 +1,74 @@
package org.fossify.messages.helpers
import android.util.Xml
import org.xmlpull.v1.XmlPullParser
object AttachmentUtils {
fun parseAttachmentNames(text: String): List<String> {
val parser = Xml.newPullParser()
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
parser.setInput(text.reader())
parser.nextTag()
return readSmil(parser)
}
private fun readSmil(parser: XmlPullParser): List<String> {
parser.require(XmlPullParser.START_TAG, null, "smil")
while (parser.next() != XmlPullParser.END_TAG) {
if (parser.eventType != XmlPullParser.START_TAG) {
continue
}
if (parser.name == "body") {
return readBody(parser)
} else {
skip(parser)
}
}
return emptyList()
}
private fun readBody(parser: XmlPullParser): List<String> {
val names = mutableListOf<String>()
parser.require(XmlPullParser.START_TAG, null, "body")
while (parser.next() != XmlPullParser.END_TAG) {
if (parser.eventType != XmlPullParser.START_TAG) {
continue
}
if (parser.name == "par") {
parser.require(XmlPullParser.START_TAG, null, "par")
while (parser.next() != XmlPullParser.END_TAG) {
if (parser.eventType != XmlPullParser.START_TAG) {
continue
}
if (parser.name == "ref") {
val value = parser.getAttributeValue(null, "src")
names.add(value)
parser.nextTag()
}
}
} else {
skip(parser)
}
}
return names
}
private fun skip(parser: XmlPullParser) {
if (parser.eventType != XmlPullParser.START_TAG) {
throw IllegalStateException()
}
var depth = 1
while (depth != 0) {
when (parser.next()) {
XmlPullParser.END_TAG -> depth--
XmlPullParser.START_TAG -> depth++
}
}
}
}

View file

@ -0,0 +1,130 @@
package org.fossify.messages.helpers
import android.content.Context
import org.fossify.commons.helpers.BaseConfig
import org.fossify.messages.extensions.getDefaultKeyboardHeight
import org.fossify.messages.models.Conversation
class Config(context: Context) : BaseConfig(context) {
companion object {
fun newInstance(context: Context) = Config(context)
}
fun saveUseSIMIdAtNumber(number: String, SIMId: Int) {
prefs.edit().putInt(USE_SIM_ID_PREFIX + number, SIMId).apply()
}
fun getUseSIMIdAtNumber(number: String) = prefs.getInt(USE_SIM_ID_PREFIX + number, 0)
var showCharacterCounter: Boolean
get() = prefs.getBoolean(SHOW_CHARACTER_COUNTER, false)
set(showCharacterCounter) = prefs.edit().putBoolean(SHOW_CHARACTER_COUNTER, showCharacterCounter).apply()
var useSimpleCharacters: Boolean
get() = prefs.getBoolean(USE_SIMPLE_CHARACTERS, false)
set(useSimpleCharacters) = prefs.edit().putBoolean(USE_SIMPLE_CHARACTERS, useSimpleCharacters).apply()
var sendOnEnter: Boolean
get() = prefs.getBoolean(SEND_ON_ENTER, false)
set(sendOnEnter) = prefs.edit().putBoolean(SEND_ON_ENTER, sendOnEnter).apply()
var enableDeliveryReports: Boolean
get() = prefs.getBoolean(ENABLE_DELIVERY_REPORTS, false)
set(enableDeliveryReports) = prefs.edit().putBoolean(ENABLE_DELIVERY_REPORTS, enableDeliveryReports).apply()
var sendLongMessageMMS: Boolean
get() = prefs.getBoolean(SEND_LONG_MESSAGE_MMS, false)
set(sendLongMessageMMS) = prefs.edit().putBoolean(SEND_LONG_MESSAGE_MMS, sendLongMessageMMS).apply()
var sendGroupMessageMMS: Boolean
get() = prefs.getBoolean(SEND_GROUP_MESSAGE_MMS, false)
set(sendGroupMessageMMS) = prefs.edit().putBoolean(SEND_GROUP_MESSAGE_MMS, sendGroupMessageMMS).apply()
var lockScreenVisibilitySetting: Int
get() = prefs.getInt(LOCK_SCREEN_VISIBILITY, LOCK_SCREEN_SENDER_MESSAGE)
set(lockScreenVisibilitySetting) = prefs.edit().putInt(LOCK_SCREEN_VISIBILITY, lockScreenVisibilitySetting).apply()
var mmsFileSizeLimit: Long
get() = prefs.getLong(MMS_FILE_SIZE_LIMIT, FILE_SIZE_600_KB)
set(mmsFileSizeLimit) = prefs.edit().putLong(MMS_FILE_SIZE_LIMIT, mmsFileSizeLimit).apply()
var pinnedConversations: Set<String>
get() = prefs.getStringSet(PINNED_CONVERSATIONS, HashSet<String>())!!
set(pinnedConversations) = prefs.edit().putStringSet(PINNED_CONVERSATIONS, pinnedConversations).apply()
fun addPinnedConversationByThreadId(threadId: Long) {
pinnedConversations = pinnedConversations.plus(threadId.toString())
}
fun addPinnedConversations(conversations: List<Conversation>) {
pinnedConversations = pinnedConversations.plus(conversations.map { it.threadId.toString() })
}
fun removePinnedConversationByThreadId(threadId: Long) {
pinnedConversations = pinnedConversations.minus(threadId.toString())
}
fun removePinnedConversations(conversations: List<Conversation>) {
pinnedConversations = pinnedConversations.minus(conversations.map { it.threadId.toString() })
}
var blockedKeywords: Set<String>
get() = prefs.getStringSet(BLOCKED_KEYWORDS, HashSet<String>())!!
set(blockedKeywords) = prefs.edit().putStringSet(BLOCKED_KEYWORDS, blockedKeywords).apply()
fun addBlockedKeyword(keyword: String) {
blockedKeywords = blockedKeywords.plus(keyword)
}
fun removeBlockedKeyword(keyword: String) {
blockedKeywords = blockedKeywords.minus(keyword)
}
var exportSms: Boolean
get() = prefs.getBoolean(EXPORT_SMS, true)
set(exportSms) = prefs.edit().putBoolean(EXPORT_SMS, exportSms).apply()
var exportMms: Boolean
get() = prefs.getBoolean(EXPORT_MMS, true)
set(exportMms) = prefs.edit().putBoolean(EXPORT_MMS, exportMms).apply()
var importSms: Boolean
get() = prefs.getBoolean(IMPORT_SMS, true)
set(importSms) = prefs.edit().putBoolean(IMPORT_SMS, importSms).apply()
var importMms: Boolean
get() = prefs.getBoolean(IMPORT_MMS, true)
set(importMms) = prefs.edit().putBoolean(IMPORT_MMS, importMms).apply()
var wasDbCleared: Boolean
get() = prefs.getBoolean(WAS_DB_CLEARED, false)
set(wasDbCleared) = prefs.edit().putBoolean(WAS_DB_CLEARED, wasDbCleared).apply()
var keyboardHeight: Int
get() = prefs.getInt(SOFT_KEYBOARD_HEIGHT, context.getDefaultKeyboardHeight())
set(keyboardHeight) = prefs.edit().putInt(SOFT_KEYBOARD_HEIGHT, keyboardHeight).apply()
var useRecycleBin: Boolean
get() = prefs.getBoolean(USE_RECYCLE_BIN, false)
set(useRecycleBin) = prefs.edit().putBoolean(USE_RECYCLE_BIN, useRecycleBin).apply()
var lastRecycleBinCheck: Long
get() = prefs.getLong(LAST_RECYCLE_BIN_CHECK, 0L)
set(lastRecycleBinCheck) = prefs.edit().putLong(LAST_RECYCLE_BIN_CHECK, lastRecycleBinCheck).apply()
var isArchiveAvailable: Boolean
get() = prefs.getBoolean(IS_ARCHIVE_AVAILABLE, true)
set(isArchiveAvailable) = prefs.edit().putBoolean(IS_ARCHIVE_AVAILABLE, isArchiveAvailable).apply()
var customNotifications: Set<String>
get() = prefs.getStringSet(CUSTOM_NOTIFICATIONS, HashSet<String>())!!
set(customNotifications) = prefs.edit().putStringSet(CUSTOM_NOTIFICATIONS, customNotifications).apply()
fun addCustomNotificationsByThreadId(threadId: Long) {
customNotifications = customNotifications.plus(threadId.toString())
}
fun removeCustomNotificationsByThreadId(threadId: Long) {
customNotifications = customNotifications.minus(threadId.toString())
}
}

View file

@ -0,0 +1,101 @@
package org.fossify.messages.helpers
import org.fossify.messages.models.Events
import org.greenrobot.eventbus.EventBus
import org.joda.time.DateTime
import org.joda.time.DateTimeZone
import kotlin.math.abs
import kotlin.random.Random
const val THREAD_ID = "thread_id"
const val THREAD_TITLE = "thread_title"
const val THREAD_TEXT = "thread_text"
const val THREAD_NUMBER = "thread_number"
const val THREAD_ATTACHMENT_URI = "thread_attachment_uri"
const val THREAD_ATTACHMENT_URIS = "thread_attachment_uris"
const val SEARCHED_MESSAGE_ID = "searched_message_id"
const val USE_SIM_ID_PREFIX = "use_sim_id_"
const val NOTIFICATION_CHANNEL = "simple_sms_messenger"
const val SHOW_CHARACTER_COUNTER = "show_character_counter"
const val USE_SIMPLE_CHARACTERS = "use_simple_characters"
const val SEND_ON_ENTER = "send_on_enter"
const val LOCK_SCREEN_VISIBILITY = "lock_screen_visibility"
const val ENABLE_DELIVERY_REPORTS = "enable_delivery_reports"
const val SEND_LONG_MESSAGE_MMS = "send_long_message_mms"
const val SEND_GROUP_MESSAGE_MMS = "send_group_message_mms"
const val MMS_FILE_SIZE_LIMIT = "mms_file_size_limit"
const val PINNED_CONVERSATIONS = "pinned_conversations"
const val BLOCKED_KEYWORDS = "blocked_keywords"
const val EXPORT_SMS = "export_sms"
const val EXPORT_MMS = "export_mms"
const val JSON_FILE_EXTENSION = ".json"
const val JSON_MIME_TYPE = "application/json"
const val XML_MIME_TYPE = "text/xml"
const val TXT_MIME_TYPE = "text/plain"
const val IMPORT_SMS = "import_sms"
const val IMPORT_MMS = "import_mms"
const val WAS_DB_CLEARED = "was_db_cleared_4"
const val EXTRA_VCARD_URI = "vcard"
const val SCHEDULED_MESSAGE_ID = "scheduled_message_id"
const val SOFT_KEYBOARD_HEIGHT = "soft_keyboard_height"
const val IS_MMS = "is_mms"
const val MESSAGE_ID = "message_id"
const val USE_RECYCLE_BIN = "use_recycle_bin"
const val LAST_RECYCLE_BIN_CHECK = "last_recycle_bin_check"
const val IS_RECYCLE_BIN = "is_recycle_bin"
const val IS_ARCHIVE_AVAILABLE = "is_archive_available"
const val CUSTOM_NOTIFICATIONS = "custom_notifications"
private const val PATH = "org.fossify.org.fossify.messages.action."
const val MARK_AS_READ = PATH + "mark_as_read"
const val REPLY = PATH + "reply"
// view types for the thread list view
const val THREAD_DATE_TIME = 1
const val THREAD_RECEIVED_MESSAGE = 2
const val THREAD_SENT_MESSAGE = 3
const val THREAD_SENT_MESSAGE_ERROR = 4
const val THREAD_SENT_MESSAGE_SENT = 5
const val THREAD_SENT_MESSAGE_SENDING = 6
const val THREAD_LOADING = 7
// view types for attachment list
const val ATTACHMENT_DOCUMENT = 7
const val ATTACHMENT_MEDIA = 8
const val ATTACHMENT_VCARD = 9
// lock screen visibility constants
const val LOCK_SCREEN_SENDER_MESSAGE = 1
const val LOCK_SCREEN_SENDER = 2
const val LOCK_SCREEN_NOTHING = 3
const val FILE_SIZE_NONE = -1L
const val FILE_SIZE_100_KB = 102_400L
const val FILE_SIZE_200_KB = 204_800L
const val FILE_SIZE_300_KB = 307_200L
const val FILE_SIZE_600_KB = 614_400L
const val FILE_SIZE_1_MB = 1_048_576L
const val FILE_SIZE_2_MB = 2_097_152L
const val MESSAGES_LIMIT = 30
// intent launch request codes
const val PICK_PHOTO_INTENT = 42
const val PICK_VIDEO_INTENT = 49
const val PICK_SAVE_FILE_INTENT = 43
const val CAPTURE_PHOTO_INTENT = 44
const val CAPTURE_VIDEO_INTENT = 45
const val CAPTURE_AUDIO_INTENT = 46
const val PICK_DOCUMENT_INTENT = 47
const val PICK_CONTACT_INTENT = 48
fun refreshMessages() {
EventBus.getDefault().post(Events.RefreshMessages())
}
/** Not to be used with real messages persisted in the telephony db. This is for internal use only (e.g. scheduled messages, notification ids etc). */
fun generateRandomId(length: Int = 9): Long {
val millis = DateTime.now(DateTimeZone.UTC).millis
val random = abs(Random(millis).nextLong())
return random.toString().takeLast(length).toLong()
}

View file

@ -0,0 +1,33 @@
package org.fossify.messages.helpers
import androidx.room.TypeConverter
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import org.fossify.commons.models.SimpleContact
import org.fossify.messages.models.Attachment
import org.fossify.messages.models.MessageAttachment
class Converters {
private val gson = Gson()
private val attachmentType = object : TypeToken<List<Attachment>>() {}.type
private val simpleContactType = object : TypeToken<List<SimpleContact>>() {}.type
private val messageAttachmentType = object : TypeToken<MessageAttachment?>() {}.type
@TypeConverter
fun jsonToAttachmentList(value: String) = gson.fromJson<ArrayList<Attachment>>(value, attachmentType)
@TypeConverter
fun attachmentListToJson(list: ArrayList<Attachment>) = gson.toJson(list)
@TypeConverter
fun jsonToSimpleContactList(value: String) = gson.fromJson<ArrayList<SimpleContact>>(value, simpleContactType)
@TypeConverter
fun simpleContactListToJson(list: ArrayList<SimpleContact>) = gson.toJson(list)
@TypeConverter
fun jsonToMessageAttachment(value: String) = gson.fromJson<MessageAttachment>(value, messageAttachmentType)
@TypeConverter
fun messageAttachmentToJson(messageAttachment: MessageAttachment?) = gson.toJson(messageAttachment)
}

View file

@ -0,0 +1,168 @@
package org.fossify.messages.helpers
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.media.ExifInterface
import android.net.Uri
import org.fossify.commons.extensions.getCompressionFormat
import org.fossify.commons.extensions.getMyFileUri
import org.fossify.commons.helpers.ensureBackgroundThread
import org.fossify.messages.extensions.extension
import org.fossify.messages.extensions.getExtensionFromMimeType
import org.fossify.messages.extensions.getFileSizeFromUri
import org.fossify.messages.extensions.isImageMimeType
import java.io.File
import java.io.FileOutputStream
import kotlin.math.roundToInt
/**
* Compress image to a given size based on
* [Compressor](https://github.com/zetbaitsu/Compressor/)
* */
class ImageCompressor(private val context: Context) {
private val contentResolver = context.contentResolver
private val outputDirectory = File(context.cacheDir, "compressed").apply {
if (!exists()) {
mkdirs()
}
}
private val minQuality = 30
private val minResolution = 56
private val scaleStepFactor = 0.6f // increase for more accurate file size at the cost increased computation
fun compressImage(uri: Uri, compressSize: Long, lossy: Boolean = compressSize < FILE_SIZE_1_MB, callback: (compressedFileUri: Uri?) -> Unit) {
ensureBackgroundThread {
try {
val fileSize = context.getFileSizeFromUri(uri)
if (fileSize > compressSize) {
val mimeType = contentResolver.getType(uri)!!
if (mimeType.isImageMimeType()) {
val byteArray = contentResolver.openInputStream(uri)?.readBytes()!!
var imageFile = File(outputDirectory, System.currentTimeMillis().toString().plus(mimeType.getExtensionFromMimeType()))
imageFile.writeBytes(byteArray)
val bitmap = loadBitmap(imageFile)
val format = if (lossy) {
Bitmap.CompressFormat.JPEG
} else {
imageFile.path.getCompressionFormat()
}
// This quality approximation mostly works for smaller images but will fail with larger images.
val compressionRatio = compressSize / fileSize.toDouble()
val quality = maxOf((compressionRatio * 100).roundToInt(), minQuality)
imageFile = overWrite(imageFile, bitmap, format = format, quality = quality)
// Even the highest quality images start to look ugly if we use 10 as the minimum quality,
// so we better save some image quality and change resolution instead. This is time consuming
// and mostly needed for very large images. Since there's no reliable way to predict the
// required resolution, we'll just iterate and find the best result.
if (imageFile.length() > compressSize) {
var scaledWidth = bitmap.width
var scaledHeight = bitmap.height
while (imageFile.length() > compressSize) {
scaledWidth = (scaledWidth * scaleStepFactor).roundToInt()
scaledHeight = (scaledHeight * scaleStepFactor).roundToInt()
if (scaledHeight < minResolution && scaledWidth < minResolution) {
break
}
imageFile = decodeSampledBitmapFromFile(imageFile, scaledWidth, scaledHeight).run {
determineImageRotation(imageFile, bitmap = this).run {
overWrite(imageFile, bitmap = this, format = format, quality = quality)
}
}
}
}
callback.invoke(context.getMyFileUri(imageFile))
} else {
callback.invoke(null)
}
} else {
// no need to compress since the file is less than the compress size
callback.invoke(uri)
}
} catch (e: Exception) {
callback.invoke(null)
}
}
}
private fun overWrite(imageFile: File, bitmap: Bitmap, format: Bitmap.CompressFormat = imageFile.path.getCompressionFormat(), quality: Int = 100): File {
val result = if (format == imageFile.path.getCompressionFormat()) {
imageFile
} else {
File("${imageFile.absolutePath.substringBeforeLast(".")}.${format.extension()}")
}
imageFile.delete()
saveBitmap(bitmap, result, format, quality)
return result
}
private fun saveBitmap(bitmap: Bitmap, destination: File, format: Bitmap.CompressFormat = destination.path.getCompressionFormat(), quality: Int = 100) {
destination.parentFile?.mkdirs()
var fileOutputStream: FileOutputStream? = null
try {
fileOutputStream = FileOutputStream(destination.absolutePath)
bitmap.compress(format, quality, fileOutputStream)
} finally {
fileOutputStream?.run {
flush()
close()
}
}
}
private fun loadBitmap(imageFile: File) = BitmapFactory.decodeFile(imageFile.absolutePath).run {
determineImageRotation(imageFile, this)
}
private fun determineImageRotation(imageFile: File, bitmap: Bitmap): Bitmap {
val exif = ExifInterface(imageFile.absolutePath)
val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, 0)
val matrix = Matrix()
when (orientation) {
6 -> matrix.postRotate(90f)
3 -> matrix.postRotate(180f)
8 -> matrix.postRotate(270f)
}
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}
private fun decodeSampledBitmapFromFile(imageFile: File, reqWidth: Int, reqHeight: Int): Bitmap {
return BitmapFactory.Options().run {
inJustDecodeBounds = true
BitmapFactory.decodeFile(imageFile.absolutePath, this)
inSampleSize = calculateInSampleSize(this, reqWidth, reqHeight)
inJustDecodeBounds = false
BitmapFactory.decodeFile(imageFile.absolutePath, this)
}
}
private fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
// Raw height and width of image
val height = options.outHeight
val width = options.outWidth
var inSampleSize = 1
if (height > reqHeight || width > reqWidth) {
val halfHeight: Int = height / 2
val halfWidth: Int = width / 2
// Calculate the largest inSampleSize value that is a power of 2 and keeps both
// height and width larger than the requested height and width.
while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) {
inSampleSize *= 2
}
}
return inSampleSize
}
}

View file

@ -0,0 +1,197 @@
package org.fossify.messages.helpers
import android.net.Uri
import android.util.Xml
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import org.fossify.commons.extensions.showErrorToast
import org.fossify.commons.extensions.toast
import org.fossify.commons.helpers.ensureBackgroundThread
import org.fossify.messages.activities.SimpleActivity
import org.fossify.messages.dialogs.ImportMessagesDialog
import org.fossify.messages.extensions.config
import org.fossify.messages.models.*
import org.xmlpull.v1.XmlPullParser
import java.io.InputStream
class MessagesImporter(private val activity: SimpleActivity) {
private val messageWriter = MessagesWriter(activity)
private val config = activity.config
private var messagesImported = 0
private var messagesFailed = 0
fun importMessages(uri: Uri) {
try {
val fileType = activity.contentResolver.getType(uri).orEmpty()
val isXml = isXmlMimeType(fileType) || (uri.path?.endsWith("txt") == true && isFileXml(uri))
if (isXml) {
activity.toast(org.fossify.commons.R.string.importing)
getInputStreamFromUri(uri)!!.importXml()
} else {
importJson(uri)
}
} catch (e: Exception) {
activity.showErrorToast(e)
}
}
private fun importJson(uri: Uri) {
try {
val jsonString = activity.contentResolver.openInputStream(uri)!!.use { inputStream ->
inputStream.bufferedReader().readText()
}
val deserializedList = Json.decodeFromString<List<MessagesBackup>>(jsonString)
if (deserializedList.isEmpty()) {
activity.toast(org.fossify.commons.R.string.no_entries_for_importing)
return
}
ImportMessagesDialog(activity, deserializedList)
} catch (e: SerializationException) {
activity.toast(org.fossify.commons.R.string.invalid_file_format)
} catch (e: IllegalArgumentException) {
activity.toast(org.fossify.commons.R.string.invalid_file_format)
} catch (e: Exception) {
activity.showErrorToast(e)
}
}
fun restoreMessages(messagesBackup: List<MessagesBackup>, callback: (ImportResult) -> Unit) {
ensureBackgroundThread {
try {
messagesBackup.forEach { message ->
try {
if (message.backupType == BackupType.SMS && config.importSms) {
messageWriter.writeSmsMessage(message as SmsBackup)
messagesImported++
} else if (message.backupType == BackupType.MMS && config.importMms) {
messageWriter.writeMmsMessage(message as MmsBackup)
messagesImported++
}
} catch (e: Exception) {
activity.showErrorToast(e)
messagesFailed++
}
}
refreshMessages()
} catch (e: Exception) {
activity.showErrorToast(e)
}
callback.invoke(
when {
messagesImported == 0 && messagesFailed == 0 -> ImportResult.IMPORT_NOTHING_NEW
messagesFailed > 0 && messagesImported > 0 -> ImportResult.IMPORT_PARTIAL
messagesFailed > 0 -> ImportResult.IMPORT_FAIL
else -> ImportResult.IMPORT_OK
}
)
}
}
private fun InputStream.importXml() {
try {
bufferedReader().use { reader ->
val xmlParser = Xml.newPullParser().apply {
setInput(reader)
}
xmlParser.nextTag()
xmlParser.require(XmlPullParser.START_TAG, null, "smses")
var depth = 1
while (depth != 0) {
when (xmlParser.next()) {
XmlPullParser.END_TAG -> depth--
XmlPullParser.START_TAG -> depth++
}
if (xmlParser.eventType != XmlPullParser.START_TAG) {
continue
}
try {
if (xmlParser.name == "sms") {
if (config.importSms) {
val message = xmlParser.readSms()
messageWriter.writeSmsMessage(message)
messagesImported++
} else {
xmlParser.skip()
}
} else {
xmlParser.skip()
}
} catch (e: Exception) {
activity.showErrorToast(e)
messagesFailed++
}
}
refreshMessages()
}
when {
messagesFailed > 0 && messagesImported > 0 -> activity.toast(org.fossify.commons.R.string.importing_some_entries_failed)
messagesFailed > 0 -> activity.toast(org.fossify.commons.R.string.importing_failed)
else -> activity.toast(org.fossify.commons.R.string.importing_successful)
}
} catch (_: Exception) {
activity.toast(org.fossify.commons.R.string.invalid_file_format)
}
}
private fun XmlPullParser.readSms(): SmsBackup {
require(XmlPullParser.START_TAG, null, "sms")
return SmsBackup(
subscriptionId = 0,
address = getAttributeValue(null, "address"),
body = getAttributeValue(null, "body"),
date = getAttributeValue(null, "date").toLong(),
dateSent = getAttributeValue(null, "date").toLong(),
locked = getAttributeValue(null, "locked").toInt(),
protocol = getAttributeValue(null, "protocol"),
read = getAttributeValue(null, "read").toInt(),
status = getAttributeValue(null, "status").toInt(),
type = getAttributeValue(null, "type").toInt(),
serviceCenter = getAttributeValue(null, "service_center")
)
}
private fun XmlPullParser.skip() {
if (eventType != XmlPullParser.START_TAG) {
throw IllegalStateException()
}
var depth = 1
while (depth != 0) {
when (next()) {
XmlPullParser.END_TAG -> depth--
XmlPullParser.START_TAG -> depth++
}
}
}
private fun getInputStreamFromUri(uri: Uri): InputStream? {
return try {
activity.contentResolver.openInputStream(uri)
} catch (e: Exception) {
null
}
}
private fun isFileXml(uri: Uri): Boolean {
val inputStream = getInputStreamFromUri(uri)
return inputStream?.bufferedReader()?.use { reader ->
reader.readLine()?.startsWith("<?xml") ?: false
} ?: false
}
private fun isXmlMimeType(mimeType: String): Boolean {
return mimeType.equals("application/xml", ignoreCase = true) || mimeType.equals("text/xml", ignoreCase = true)
}
private fun isJsonMimeType(mimeType: String): Boolean {
return mimeType.equals("application/json", ignoreCase = true)
}
}

View file

@ -0,0 +1,256 @@
package org.fossify.messages.helpers
import android.annotation.SuppressLint
import android.content.Context
import android.net.Uri
import android.provider.Telephony.Mms
import android.provider.Telephony.Sms
import android.util.Base64
import org.fossify.commons.extensions.*
import org.fossify.commons.helpers.isQPlus
import org.fossify.commons.helpers.isRPlus
import org.fossify.messages.extensions.getConversationIds
import org.fossify.messages.models.*
import java.io.IOException
import java.io.InputStream
class MessagesReader(private val context: Context) {
fun getMessagesToExport(
getSms: Boolean, getMms: Boolean, callback: (messages: List<MessagesBackup>) -> Unit
) {
val conversationIds = context.getConversationIds()
var smsMessages = listOf<SmsBackup>()
var mmsMessages = listOf<MmsBackup>()
if (getSms) {
smsMessages = getSmsMessages(conversationIds)
}
if (getMms) {
mmsMessages = getMmsMessages(conversationIds)
}
callback(smsMessages + mmsMessages)
}
private fun getSmsMessages(threadIds: List<Long>): List<SmsBackup> {
val projection = arrayOf(
Sms.SUBSCRIPTION_ID,
Sms.ADDRESS,
Sms.BODY,
Sms.DATE,
Sms.DATE_SENT,
Sms.LOCKED,
Sms.PROTOCOL,
Sms.READ,
Sms.STATUS,
Sms.TYPE,
Sms.SERVICE_CENTER
)
val selection = "${Sms.THREAD_ID} = ?"
val smsList = mutableListOf<SmsBackup>()
threadIds.map { it.toString() }.forEach { threadId ->
context.queryCursor(Sms.CONTENT_URI, projection, selection, arrayOf(threadId)) { cursor ->
val subscriptionId = cursor.getLongValue(Sms.SUBSCRIPTION_ID)
val address = cursor.getStringValue(Sms.ADDRESS)
val body = cursor.getStringValueOrNull(Sms.BODY)
val date = cursor.getLongValue(Sms.DATE)
val dateSent = cursor.getLongValue(Sms.DATE_SENT)
val locked = cursor.getIntValue(Sms.DATE_SENT)
val protocol = cursor.getStringValueOrNull(Sms.PROTOCOL)
val read = cursor.getIntValue(Sms.READ)
val status = cursor.getIntValue(Sms.STATUS)
val type = cursor.getIntValue(Sms.TYPE)
val serviceCenter = cursor.getStringValueOrNull(Sms.SERVICE_CENTER)
smsList.add(SmsBackup(subscriptionId, address, body, date, dateSent, locked, protocol, read, status, type, serviceCenter))
}
}
return smsList
}
private fun getMmsMessages(threadIds: List<Long>, includeTextOnlyAttachment: Boolean = false): List<MmsBackup> {
val projection = arrayOf(
Mms._ID,
Mms.CREATOR,
Mms.CONTENT_TYPE,
Mms.DELIVERY_REPORT,
Mms.DATE,
Mms.DATE_SENT,
Mms.LOCKED,
Mms.MESSAGE_TYPE,
Mms.MESSAGE_BOX,
Mms.READ,
Mms.READ_REPORT,
Mms.SEEN,
Mms.TEXT_ONLY,
Mms.STATUS,
Mms.SUBJECT_CHARSET,
Mms.SUBSCRIPTION_ID,
Mms.TRANSACTION_ID
)
val selection = if (includeTextOnlyAttachment) {
"${Mms.THREAD_ID} = ? AND ${Mms.TEXT_ONLY} = ?"
} else {
"${Mms.THREAD_ID} = ?"
}
val mmsList = mutableListOf<MmsBackup>()
threadIds.map { it.toString() }.forEach { threadId ->
val selectionArgs = if (includeTextOnlyAttachment) {
arrayOf(threadId, "1")
} else {
arrayOf(threadId)
}
context.queryCursor(Mms.CONTENT_URI, projection, selection, selectionArgs) { cursor ->
val mmsId = cursor.getLongValue(Mms._ID)
val creator = cursor.getStringValueOrNull(Mms.CREATOR)
val contentType = cursor.getStringValueOrNull(Mms.CONTENT_TYPE)
val deliveryReport = cursor.getIntValue(Mms.DELIVERY_REPORT)
val date = cursor.getLongValue(Mms.DATE)
val dateSent = cursor.getLongValue(Mms.DATE_SENT)
val locked = cursor.getIntValue(Mms.LOCKED)
val messageType = cursor.getIntValue(Mms.MESSAGE_TYPE)
val messageBox = cursor.getIntValue(Mms.MESSAGE_BOX)
val read = cursor.getIntValue(Mms.READ)
val readReport = cursor.getIntValue(Mms.READ_REPORT)
val seen = cursor.getIntValue(Mms.SEEN)
val textOnly = cursor.getIntValue(Mms.TEXT_ONLY)
val status = cursor.getStringValueOrNull(Mms.STATUS)
val subject = cursor.getStringValueOrNull(Mms.SUBJECT)
val subjectCharSet = cursor.getStringValueOrNull(Mms.SUBJECT_CHARSET)
val subscriptionId = cursor.getLongValue(Mms.SUBSCRIPTION_ID)
val transactionId = cursor.getStringValueOrNull(Mms.TRANSACTION_ID)
val parts = getParts(mmsId)
val addresses = getMmsAddresses(mmsId)
mmsList.add(
MmsBackup(
creator,
contentType,
deliveryReport,
date,
dateSent,
locked,
messageType,
messageBox,
read,
readReport,
seen,
textOnly,
status,
subject,
subjectCharSet,
subscriptionId,
transactionId,
addresses,
parts
)
)
}
}
return mmsList
}
@SuppressLint("NewApi")
private fun getParts(mmsId: Long): List<MmsPart> {
val parts = mutableListOf<MmsPart>()
val uri = if (isQPlus()) Mms.Part.CONTENT_URI else Uri.parse("content://mms/part")
val projection = arrayOf(
Mms.Part._ID,
Mms.Part.CONTENT_DISPOSITION,
Mms.Part.CHARSET,
Mms.Part.CONTENT_ID,
Mms.Part.CONTENT_LOCATION,
Mms.Part.CONTENT_TYPE,
Mms.Part.CT_START,
Mms.Part.CT_TYPE,
Mms.Part.FILENAME,
Mms.Part.NAME,
Mms.Part.SEQ,
Mms.Part.TEXT
)
val selection = "${Mms.Part.MSG_ID} = ?"
val selectionArgs = arrayOf(mmsId.toString())
context.queryCursor(uri, projection, selection, selectionArgs) { cursor ->
val partId = cursor.getLongValue(Mms.Part._ID)
val contentDisposition = cursor.getStringValueOrNull(Mms.Part.CONTENT_DISPOSITION)
val charset = cursor.getStringValueOrNull(Mms.Part.CHARSET)
val contentId = cursor.getStringValueOrNull(Mms.Part.CONTENT_ID)
val contentLocation = cursor.getStringValueOrNull(Mms.Part.CONTENT_LOCATION)
val contentType = cursor.getStringValue(Mms.Part.CONTENT_TYPE)
val ctStart = cursor.getStringValueOrNull(Mms.Part.CT_START)
val ctType = cursor.getStringValueOrNull(Mms.Part.CT_TYPE)
val filename = cursor.getStringValueOrNull(Mms.Part.FILENAME)
val name = cursor.getStringValueOrNull(Mms.Part.NAME)
val sequenceOrder = cursor.getIntValue(Mms.Part.SEQ)
val text = cursor.getStringValueOrNull(Mms.Part.TEXT)
val data = when {
contentType.startsWith("text/") -> {
usePart(partId) { stream ->
stream.readBytes().toString(Charsets.UTF_8)
}
}
else -> {
usePart(partId) { stream ->
Base64.encodeToString(stream.readBytes(), Base64.DEFAULT)
}
}
}
parts.add(MmsPart(contentDisposition, charset, contentId, contentLocation, contentType, ctStart, ctType, filename, name, sequenceOrder, text, data))
}
return parts
}
@SuppressLint("NewApi")
private fun usePart(partId: Long, block: (InputStream) -> String): String {
val partUri = if (isQPlus()) Mms.Part.CONTENT_URI.buildUpon().appendPath(partId.toString()).build() else Uri.parse("content://mms/part/$partId")
try {
val stream = context.contentResolver.openInputStream(partUri) ?: return ""
stream.use {
return block(stream)
}
} catch (e: IOException) {
return ""
}
}
@SuppressLint("NewApi")
private fun getMmsAddresses(messageId: Long): List<MmsAddress> {
val addresses = mutableListOf<MmsAddress>()
val uri = if (isRPlus()) Mms.Addr.getAddrUriForMessage(messageId.toString()) else Uri.parse("content://mms/$messageId/addr")
val projection = arrayOf(Mms.Addr.ADDRESS, Mms.Addr.TYPE, Mms.Addr.CHARSET)
val selection = "${Mms.Addr.MSG_ID}= ?"
val selectionArgs = arrayOf(messageId.toString())
context.queryCursor(uri, projection, selection, selectionArgs) { cursor ->
val address = cursor.getStringValue(Mms.Addr.ADDRESS)
val type = cursor.getIntValue(Mms.Addr.TYPE)
val charset = cursor.getIntValue(Mms.Addr.CHARSET)
addresses.add(MmsAddress(address, type, charset))
}
return addresses
}
fun getMessagesCount(): Int {
return getSmsCount() + getMmsCount()
}
fun getMmsCount(): Int {
return countRows(Mms.CONTENT_URI)
}
fun getSmsCount(): Int {
return countRows(Sms.CONTENT_URI)
}
private fun countRows(uri: Uri): Int {
val cursor = context.contentResolver.query(
uri, null, null, null, null
) ?: return 0
cursor.use {
return cursor.count
}
}
}

View file

@ -0,0 +1,155 @@
package org.fossify.messages.helpers
import android.annotation.SuppressLint
import android.content.Context
import android.net.Uri
import android.provider.Telephony.Mms
import android.provider.Telephony.Sms
import android.util.Base64
import com.google.android.mms.pdu_alt.PduHeaders
import com.klinker.android.send_message.Utils
import org.fossify.commons.extensions.getLongValue
import org.fossify.commons.extensions.queryCursor
import org.fossify.commons.helpers.isRPlus
import org.fossify.messages.models.MmsAddress
import org.fossify.messages.models.MmsBackup
import org.fossify.messages.models.MmsPart
import org.fossify.messages.models.SmsBackup
class MessagesWriter(private val context: Context) {
private val INVALID_ID = -1L
private val contentResolver = context.contentResolver
fun writeSmsMessage(smsBackup: SmsBackup) {
val contentValues = smsBackup.toContentValues()
val threadId = Utils.getOrCreateThreadId(context, smsBackup.address)
contentValues.put(Sms.THREAD_ID, threadId)
if (!smsExist(smsBackup)) {
contentResolver.insert(Sms.CONTENT_URI, contentValues)
}
}
private fun smsExist(smsBackup: SmsBackup): Boolean {
val uri = Sms.CONTENT_URI
val projection = arrayOf(Sms._ID)
val selection = "${Sms.DATE} = ? AND ${Sms.ADDRESS} = ? AND ${Sms.TYPE} = ?"
val selectionArgs = arrayOf(smsBackup.date.toString(), smsBackup.address, smsBackup.type.toString())
var exists = false
context.queryCursor(uri, projection, selection, selectionArgs) {
exists = it.count > 0
}
return exists
}
fun writeMmsMessage(mmsBackup: MmsBackup) {
// 1. write mms msg, get the msg_id, check if mms exists before writing
// 2. write parts - parts depend on the msg id, check if part exist before writing, write data if it is a non-text part
// 3. write the addresses, address depends on msg id too, check if address exist before writing
val contentValues = mmsBackup.toContentValues()
val threadId = getMmsThreadId(mmsBackup)
if (threadId != INVALID_ID) {
contentValues.put(Mms.THREAD_ID, threadId)
if (!mmsExist(mmsBackup)) {
contentResolver.insert(Mms.CONTENT_URI, contentValues)
}
val messageId = getMmsId(mmsBackup)
if (messageId != INVALID_ID) {
mmsBackup.parts.forEach { writeMmsPart(it, messageId) }
mmsBackup.addresses.forEach { writeMmsAddress(it, messageId) }
}
}
}
private fun getMmsThreadId(mmsBackup: MmsBackup): Long {
val address = when (mmsBackup.messageBox) {
Mms.MESSAGE_BOX_INBOX -> mmsBackup.addresses.firstOrNull { it.type == PduHeaders.FROM }?.address
else -> mmsBackup.addresses.firstOrNull { it.type == PduHeaders.TO }?.address
}
return if (!address.isNullOrEmpty()) {
Utils.getOrCreateThreadId(context, address)
} else {
INVALID_ID
}
}
private fun getMmsId(mmsBackup: MmsBackup): Long {
val threadId = getMmsThreadId(mmsBackup)
val uri = Mms.CONTENT_URI
val projection = arrayOf(Mms._ID)
val selection = "${Mms.DATE} = ? AND ${Mms.DATE_SENT} = ? AND ${Mms.THREAD_ID} = ? AND ${Mms.MESSAGE_BOX} = ?"
val selectionArgs = arrayOf(mmsBackup.date.toString(), mmsBackup.dateSent.toString(), threadId.toString(), mmsBackup.messageBox.toString())
var id = INVALID_ID
context.queryCursor(uri, projection, selection, selectionArgs) {
id = it.getLongValue(Mms._ID)
}
return id
}
private fun mmsExist(mmsBackup: MmsBackup): Boolean {
return getMmsId(mmsBackup) != INVALID_ID
}
@SuppressLint("NewApi")
private fun mmsAddressExist(mmsAddress: MmsAddress, messageId: Long): Boolean {
val addressUri = if (isRPlus()) Mms.Addr.getAddrUriForMessage(messageId.toString()) else Uri.parse("content://mms/$messageId/addr")
val projection = arrayOf(Mms.Addr._ID)
val selection = "${Mms.Addr.TYPE} = ? AND ${Mms.Addr.ADDRESS} = ? AND ${Mms.Addr.MSG_ID} = ?"
val selectionArgs = arrayOf(mmsAddress.type.toString(), mmsAddress.address.toString(), messageId.toString())
var exists = false
context.queryCursor(addressUri, projection, selection, selectionArgs) {
exists = it.count > 0
}
return exists
}
@SuppressLint("NewApi")
private fun writeMmsAddress(mmsAddress: MmsAddress, messageId: Long) {
if (!mmsAddressExist(mmsAddress, messageId)) {
val addressUri = if (isRPlus()) {
Mms.Addr.getAddrUriForMessage(messageId.toString())
} else {
Uri.parse("content://mms/$messageId/addr")
}
val contentValues = mmsAddress.toContentValues()
contentValues.put(Mms.Addr.MSG_ID, messageId)
contentResolver.insert(addressUri, contentValues)
}
}
@SuppressLint("NewApi")
private fun writeMmsPart(mmsPart: MmsPart, messageId: Long) {
if (!mmsPartExist(mmsPart, messageId)) {
val uri = Uri.parse("content://mms/${messageId}/part")
val contentValues = mmsPart.toContentValues()
contentValues.put(Mms.Part.MSG_ID, messageId)
val partUri = contentResolver.insert(uri, contentValues)
try {
if (partUri != null) {
if (mmsPart.isNonText()) {
contentResolver.openOutputStream(partUri).use {
val arr = Base64.decode(mmsPart.data, Base64.DEFAULT)
it!!.write(arr)
}
}
}
} catch (e: Exception) {
}
}
}
@SuppressLint("NewApi")
private fun mmsPartExist(mmsPart: MmsPart, messageId: Long): Boolean {
val uri = Uri.parse("content://mms/${messageId}/part")
val projection = arrayOf(Mms.Part._ID)
val selection = "${Mms.Part.CONTENT_LOCATION} = ? AND ${Mms.Part.CONTENT_TYPE} = ? AND ${Mms.Part.MSG_ID} = ? AND ${Mms.Part.CONTENT_ID} = ?"
val selectionArgs = arrayOf(mmsPart.contentLocation.toString(), mmsPart.contentType, messageId.toString(), mmsPart.contentId.toString())
var exists = false
context.queryCursor(uri, projection, selection, selectionArgs) {
exists = it.count > 0
}
return exists
}
}

View file

@ -0,0 +1,230 @@
package org.fossify.messages.helpers
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager.IMPORTANCE_HIGH
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.media.AudioAttributes
import android.media.AudioManager
import android.media.RingtoneManager
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import androidx.core.app.RemoteInput
import org.fossify.commons.extensions.getProperPrimaryColor
import org.fossify.commons.extensions.notificationManager
import org.fossify.commons.helpers.SimpleContactsHelper
import org.fossify.commons.helpers.isNougatPlus
import org.fossify.commons.helpers.isOreoPlus
import org.fossify.messages.R
import org.fossify.messages.activities.ThreadActivity
import org.fossify.messages.extensions.config
import org.fossify.messages.messaging.isShortCodeWithLetters
import org.fossify.messages.receivers.DeleteSmsReceiver
import org.fossify.messages.receivers.DirectReplyReceiver
import org.fossify.messages.receivers.MarkAsReadReceiver
class NotificationHelper(private val context: Context) {
private val notificationManager = context.notificationManager
private val soundUri get() = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
private val user = Person.Builder()
.setName(context.getString(R.string.me))
.build()
@SuppressLint("NewApi")
fun showMessageNotification(
messageId: Long,
address: String,
body: String,
threadId: Long,
bitmap: Bitmap?,
sender: String?,
alertOnlyOnce: Boolean = false
) {
val hasCustomNotifications = context.config.customNotifications.contains(threadId.toString())
val notificationChannelId = if (hasCustomNotifications) threadId.toString() else NOTIFICATION_CHANNEL
if (!hasCustomNotifications) {
maybeCreateChannel(notificationChannelId, context.getString(R.string.channel_received_sms))
}
val notificationId = threadId.hashCode()
val contentIntent = Intent(context, ThreadActivity::class.java).apply {
putExtra(THREAD_ID, threadId)
}
val contentPendingIntent =
PendingIntent.getActivity(context, notificationId, contentIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE)
val markAsReadIntent = Intent(context, MarkAsReadReceiver::class.java).apply {
action = MARK_AS_READ
putExtra(THREAD_ID, threadId)
}
val markAsReadPendingIntent =
PendingIntent.getBroadcast(context, notificationId, markAsReadIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE)
val deleteSmsIntent = Intent(context, DeleteSmsReceiver::class.java).apply {
putExtra(THREAD_ID, threadId)
putExtra(MESSAGE_ID, messageId)
}
val deleteSmsPendingIntent =
PendingIntent.getBroadcast(context, notificationId, deleteSmsIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE)
var replyAction: NotificationCompat.Action? = null
val isNoReplySms = isShortCodeWithLetters(address)
if (isNougatPlus() && !isNoReplySms) {
val replyLabel = context.getString(R.string.reply)
val remoteInput = RemoteInput.Builder(REPLY)
.setLabel(replyLabel)
.build()
val replyIntent = Intent(context, DirectReplyReceiver::class.java).apply {
putExtra(THREAD_ID, threadId)
putExtra(THREAD_NUMBER, address)
}
val replyPendingIntent =
PendingIntent.getBroadcast(
context.applicationContext,
notificationId,
replyIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
)
replyAction = NotificationCompat.Action.Builder(R.drawable.ic_send_vector, replyLabel, replyPendingIntent)
.addRemoteInput(remoteInput)
.build()
}
val largeIcon = bitmap ?: if (sender != null) {
SimpleContactsHelper(context).getContactLetterIcon(sender)
} else {
null
}
val builder = NotificationCompat.Builder(context, notificationChannelId).apply {
when (context.config.lockScreenVisibilitySetting) {
LOCK_SCREEN_SENDER_MESSAGE -> {
setLargeIcon(largeIcon)
setStyle(getMessagesStyle(address, body, notificationId, sender))
}
LOCK_SCREEN_SENDER -> {
setContentTitle(sender)
setLargeIcon(largeIcon)
val summaryText = context.getString(R.string.new_message)
setStyle(NotificationCompat.BigTextStyle().setSummaryText(summaryText).bigText(body))
}
}
color = context.getProperPrimaryColor()
setSmallIcon(R.drawable.ic_messenger)
setContentIntent(contentPendingIntent)
priority = NotificationCompat.PRIORITY_MAX
setDefaults(Notification.DEFAULT_LIGHTS)
setCategory(Notification.CATEGORY_MESSAGE)
setAutoCancel(true)
setOnlyAlertOnce(alertOnlyOnce)
setSound(soundUri, AudioManager.STREAM_NOTIFICATION)
}
if (replyAction != null && context.config.lockScreenVisibilitySetting == LOCK_SCREEN_SENDER_MESSAGE) {
builder.addAction(replyAction)
}
builder.addAction(org.fossify.commons.R.drawable.ic_check_vector, context.getString(R.string.mark_as_read), markAsReadPendingIntent)
.setChannelId(notificationChannelId)
if (isNoReplySms) {
builder.addAction(
org.fossify.commons.R.drawable.ic_delete_vector,
context.getString(org.fossify.commons.R.string.delete),
deleteSmsPendingIntent
).setChannelId(notificationChannelId)
}
notificationManager.notify(notificationId, builder.build())
}
@SuppressLint("NewApi")
fun showSendingFailedNotification(recipientName: String, threadId: Long) {
val hasCustomNotifications = context.config.customNotifications.contains(threadId.toString())
val notificationChannelId = if (hasCustomNotifications) threadId.toString() else NOTIFICATION_CHANNEL
if (!hasCustomNotifications) {
maybeCreateChannel(notificationChannelId, context.getString(R.string.message_not_sent_short))
}
val notificationId = generateRandomId().hashCode()
val intent = Intent(context, ThreadActivity::class.java).apply {
putExtra(THREAD_ID, threadId)
}
val contentPendingIntent = PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE)
val summaryText = String.format(context.getString(R.string.message_sending_error), recipientName)
val largeIcon = SimpleContactsHelper(context).getContactLetterIcon(recipientName)
val builder = NotificationCompat.Builder(context, notificationChannelId)
.setContentTitle(context.getString(R.string.message_not_sent_short))
.setContentText(summaryText)
.setColor(context.getProperPrimaryColor())
.setSmallIcon(R.drawable.ic_messenger)
.setLargeIcon(largeIcon)
.setStyle(NotificationCompat.BigTextStyle().bigText(summaryText))
.setContentIntent(contentPendingIntent)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setDefaults(Notification.DEFAULT_LIGHTS)
.setCategory(Notification.CATEGORY_MESSAGE)
.setAutoCancel(true)
.setChannelId(notificationChannelId)
notificationManager.notify(notificationId, builder.build())
}
private fun maybeCreateChannel(id: String, name: String) {
if (isOreoPlus()) {
val audioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setLegacyStreamType(AudioManager.STREAM_NOTIFICATION)
.build()
val importance = IMPORTANCE_HIGH
NotificationChannel(id, name, importance).apply {
setBypassDnd(false)
enableLights(true)
setSound(soundUri, audioAttributes)
enableVibration(true)
notificationManager.createNotificationChannel(this)
}
}
}
private fun getMessagesStyle(address: String, body: String, notificationId: Int, name: String?): NotificationCompat.MessagingStyle {
val sender = if (name != null) {
Person.Builder()
.setName(name)
.setKey(address)
.build()
} else {
null
}
return NotificationCompat.MessagingStyle(user).also { style ->
getOldMessages(notificationId).forEach {
style.addMessage(it)
}
val newMessage = NotificationCompat.MessagingStyle.Message(body, System.currentTimeMillis(), sender)
style.addMessage(newMessage)
}
}
private fun getOldMessages(notificationId: Int): List<NotificationCompat.MessagingStyle.Message> {
if (!isNougatPlus()) {
return emptyList()
}
val currentNotification = notificationManager.activeNotifications.find { it.id == notificationId }
return if (currentNotification != null) {
val activeStyle = NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(currentNotification.notification)
activeStyle?.messages.orEmpty()
} else {
emptyList()
}
}
}

View file

@ -0,0 +1,37 @@
package org.fossify.messages.helpers
import android.content.Context
import android.net.Uri
import ezvcard.Ezvcard
import ezvcard.VCard
import org.fossify.commons.helpers.ensureBackgroundThread
fun parseVCardFromUri(context: Context, uri: Uri, callback: (vCards: List<VCard>) -> Unit) {
ensureBackgroundThread {
val inputStream = try {
context.contentResolver.openInputStream(uri)
} catch (e: Exception) {
callback(emptyList())
return@ensureBackgroundThread
}
val vCards = Ezvcard.parse(inputStream).all()
callback(vCards)
}
}
fun VCard?.parseNameFromVCard(): String? {
if (this == null) return null
var fullName = formattedName?.value
if (fullName.isNullOrEmpty()) {
val structured = structuredName ?: return null
val nameComponents = arrayListOf<String?>().apply {
addAll(structured.prefixes)
add(structured.given)
addAll(structured.additionalNames)
add(structured.family)
addAll(structured.suffixes)
}
fullName = nameComponents.filter { !it.isNullOrEmpty() }.joinToString(separator = " ")
}
return fullName
}

View file

@ -0,0 +1,11 @@
package org.fossify.messages.interfaces
import androidx.room.Dao
import androidx.room.Query
import org.fossify.messages.models.Attachment
@Dao
interface AttachmentsDao {
@Query("SELECT * FROM attachments")
fun getAll(): List<Attachment>
}

View file

@ -0,0 +1,59 @@
package org.fossify.messages.interfaces
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import org.fossify.messages.models.Conversation
import org.fossify.messages.models.ConversationWithSnippetOverride
@Dao
interface ConversationsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertOrUpdate(conversation: Conversation): Long
@Query("SELECT (SELECT body FROM messages LEFT OUTER JOIN recycle_bin_messages ON messages.id = recycle_bin_messages.id WHERE recycle_bin_messages.id IS NULL AND messages.thread_id = conversations.thread_id ORDER BY messages.date DESC LIMIT 1) as new_snippet, * FROM conversations WHERE archived = 0")
fun getNonArchivedWithLatestSnippet(): List<ConversationWithSnippetOverride>
fun getNonArchived(): List<Conversation> {
return getNonArchivedWithLatestSnippet().map { it.toConversation() }
}
@Query("SELECT (SELECT body FROM messages LEFT OUTER JOIN recycle_bin_messages ON messages.id = recycle_bin_messages.id WHERE recycle_bin_messages.id IS NULL AND messages.thread_id = conversations.thread_id ORDER BY messages.date DESC LIMIT 1) as new_snippet, * FROM conversations WHERE archived = 1")
fun getAllArchivedWithLatestSnippet(): List<ConversationWithSnippetOverride>
fun getAllArchived(): List<Conversation> {
return getAllArchivedWithLatestSnippet().map { it.toConversation() }
}
@Query("SELECT (SELECT body FROM messages LEFT OUTER JOIN recycle_bin_messages ON messages.id = recycle_bin_messages.id WHERE recycle_bin_messages.id IS NOT NULL AND messages.thread_id = conversations.thread_id ORDER BY messages.date DESC LIMIT 1) as new_snippet, * FROM conversations WHERE (SELECT COUNT(*) FROM messages LEFT OUTER JOIN recycle_bin_messages ON messages.id = recycle_bin_messages.id WHERE recycle_bin_messages.id IS NOT NULL AND messages.thread_id = conversations.thread_id) > 0")
fun getAllWithMessagesInRecycleBinWithLatestSnippet(): List<ConversationWithSnippetOverride>
fun getAllWithMessagesInRecycleBin(): List<Conversation> {
return getAllWithMessagesInRecycleBinWithLatestSnippet().map { it.toConversation() }
}
@Query("SELECT * FROM conversations WHERE thread_id = :threadId")
fun getConversationWithThreadId(threadId: Long): Conversation?
@Query("SELECT * FROM conversations WHERE read = 0")
fun getUnreadConversations(): List<Conversation>
@Query("SELECT * FROM conversations WHERE title LIKE :text")
fun getConversationsWithText(text: String): List<Conversation>
@Query("UPDATE conversations SET read = 1 WHERE thread_id = :threadId")
fun markRead(threadId: Long)
@Query("UPDATE conversations SET read = 0 WHERE thread_id = :threadId")
fun markUnread(threadId: Long)
@Query("UPDATE conversations SET archived = 1 WHERE thread_id = :threadId")
fun moveToArchive(threadId: Long)
@Query("UPDATE conversations SET archived = 0 WHERE thread_id = :threadId")
fun unarchive(threadId: Long)
@Query("DELETE FROM conversations WHERE thread_id = :threadId")
fun deleteThreadId(threadId: Long)
}

View file

@ -0,0 +1,11 @@
package org.fossify.messages.interfaces
import androidx.room.Dao
import androidx.room.Query
import org.fossify.messages.models.MessageAttachment
@Dao
interface MessageAttachmentsDao {
@Query("SELECT * FROM message_attachments")
fun getAll(): List<MessageAttachment>
}

View file

@ -0,0 +1,89 @@
package org.fossify.messages.interfaces
import androidx.room.*
import org.fossify.messages.models.Message
import org.fossify.messages.models.RecycleBinMessage
@Dao
interface MessagesDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertOrUpdate(message: Message)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertRecycleBinEntry(recycleBinMessage: RecycleBinMessage)
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun insertOrIgnore(message: Message): Long
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertMessages(vararg message: Message)
@Query("SELECT * FROM messages")
fun getAll(): List<Message>
@Query("SELECT messages.* FROM messages LEFT OUTER JOIN recycle_bin_messages ON messages.id = recycle_bin_messages.id WHERE recycle_bin_messages.id IS NOT NULL")
fun getAllRecycleBinMessages(): List<Message>
@Query("SELECT messages.* FROM messages LEFT OUTER JOIN recycle_bin_messages ON messages.id = recycle_bin_messages.id WHERE recycle_bin_messages.id IS NOT NULL AND recycle_bin_messages.deleted_ts < :timestamp")
fun getOldRecycleBinMessages(timestamp: Long): List<Message>
@Query("SELECT * FROM messages WHERE thread_id = :threadId")
fun getThreadMessages(threadId: Long): List<Message>
@Query("SELECT messages.* FROM messages LEFT OUTER JOIN recycle_bin_messages ON messages.id = recycle_bin_messages.id WHERE recycle_bin_messages.id IS NULL AND thread_id = :threadId")
fun getNonRecycledThreadMessages(threadId: Long): List<Message>
@Query("SELECT messages.* FROM messages LEFT OUTER JOIN recycle_bin_messages ON messages.id = recycle_bin_messages.id WHERE recycle_bin_messages.id IS NOT NULL AND thread_id = :threadId")
fun getThreadMessagesFromRecycleBin(threadId: Long): List<Message>
@Query("SELECT messages.* FROM messages LEFT OUTER JOIN recycle_bin_messages ON messages.id = recycle_bin_messages.id WHERE recycle_bin_messages.id IS NULL AND thread_id = :threadId AND is_scheduled = 1")
fun getScheduledThreadMessages(threadId: Long): List<Message>
@Query("SELECT * FROM messages WHERE thread_id = :threadId AND id = :messageId AND is_scheduled = 1")
fun getScheduledMessageWithId(threadId: Long, messageId: Long): Message
@Query("SELECT COUNT(*) FROM recycle_bin_messages")
fun getArchivedCount(): Int
@Query("SELECT * FROM messages WHERE body LIKE :text")
fun getMessagesWithText(text: String): List<Message>
@Query("UPDATE messages SET read = 1 WHERE id = :id")
fun markRead(id: Long)
@Query("UPDATE messages SET read = 1 WHERE thread_id = :threadId")
fun markThreadRead(threadId: Long)
@Query("UPDATE messages SET type = :type WHERE id = :id")
fun updateType(id: Long, type: Int): Int
@Query("UPDATE messages SET status = :status WHERE id = :id")
fun updateStatus(id: Long, status: Int): Int
@Transaction
fun delete(id: Long) {
deleteFromMessages(id)
deleteFromRecycleBin(id)
}
@Query("DELETE FROM messages WHERE id = :id")
fun deleteFromMessages(id: Long)
@Query("DELETE FROM recycle_bin_messages WHERE id = :id")
fun deleteFromRecycleBin(id: Long)
@Transaction
fun deleteThreadMessages(threadId: Long) {
deleteThreadMessagesFromRecycleBin(threadId)
deleteAllThreadMessages(threadId)
}
@Query("DELETE FROM messages WHERE thread_id = :threadId")
fun deleteAllThreadMessages(threadId: Long)
@Query("DELETE FROM recycle_bin_messages WHERE id IN (SELECT id FROM messages WHERE thread_id = :threadId)")
fun deleteThreadMessagesFromRecycleBin(threadId: Long)
@Query("DELETE FROM messages")
fun deleteAll()
}

View file

@ -0,0 +1,86 @@
package org.fossify.messages.messaging
import android.content.Context
import android.telephony.SmsMessage
import android.widget.Toast.LENGTH_LONG
import com.klinker.android.send_message.Settings
import org.fossify.commons.extensions.showErrorToast
import org.fossify.commons.extensions.toast
import org.fossify.messages.R
import org.fossify.messages.extensions.config
import org.fossify.messages.extensions.messagingUtils
import org.fossify.messages.messaging.SmsException.Companion.EMPTY_DESTINATION_ADDRESS
import org.fossify.messages.messaging.SmsException.Companion.ERROR_PERSISTING_MESSAGE
import org.fossify.messages.messaging.SmsException.Companion.ERROR_SENDING_MESSAGE
import org.fossify.messages.models.Attachment
@Deprecated("TODO: Move/rewrite messaging config code into the app.")
fun Context.getSendMessageSettings(): Settings {
val settings = Settings()
settings.useSystemSending = true
settings.deliveryReports = config.enableDeliveryReports
settings.sendLongAsMms = config.sendLongMessageMMS
settings.sendLongAsMmsAfter = 1
settings.group = config.sendGroupMessageMMS
return settings
}
fun Context.isLongMmsMessage(text: String, settings: Settings = getSendMessageSettings()): Boolean {
val data = SmsMessage.calculateLength(text, false)
val numPages = data.first()
return numPages > settings.sendLongAsMmsAfter && settings.sendLongAsMms
}
/** Sends the message using the in-app SmsManager API wrappers if it's an SMS or using android-smsmms for MMS. */
fun Context.sendMessageCompat(text: String, addresses: List<String>, subId: Int?, attachments: List<Attachment>, messageId: Long? = null) {
val settings = getSendMessageSettings()
if (subId != null) {
settings.subscriptionId = subId
}
val messagingUtils = messagingUtils
val isMms = attachments.isNotEmpty() || isLongMmsMessage(text, settings) || addresses.size > 1 && settings.group
if (isMms) {
// we send all MMS attachments separately to reduces the chances of hitting provider MMS limit.
if (attachments.isNotEmpty()) {
val lastIndex = attachments.lastIndex
if (attachments.size > 1) {
for (i in 0 until lastIndex) {
val attachment = attachments[i]
messagingUtils.sendMmsMessage("", addresses, attachment, settings, messageId)
}
}
val lastAttachment = attachments[lastIndex]
messagingUtils.sendMmsMessage(text, addresses, lastAttachment, settings, messageId)
} else {
messagingUtils.sendMmsMessage(text, addresses, null, settings, messageId)
}
} else {
try {
messagingUtils.sendSmsMessage(text, addresses.toSet(), settings.subscriptionId, settings.deliveryReports, messageId)
} catch (e: SmsException) {
when (e.errorCode) {
EMPTY_DESTINATION_ADDRESS -> toast(id = R.string.empty_destination_address, length = LENGTH_LONG)
ERROR_PERSISTING_MESSAGE -> toast(id = R.string.unable_to_save_message, length = LENGTH_LONG)
ERROR_SENDING_MESSAGE -> toast(
msg = getString(R.string.unknown_error_occurred_sending_message, e.errorCode),
length = LENGTH_LONG
)
}
} catch (e: Exception) {
showErrorToast(e)
}
}
}
/**
* Check if a given "address" is a short code.
* There's not much info available on these special numbers, even the wikipedia page (https://en.wikipedia.org/wiki/Short_code)
* contains outdated information regarding max number of digits. The exact parameters for short codes can vary by country and by carrier.
*
* This function simply returns true if the [address] contains at least one letter.
*/
fun isShortCodeWithLetters(address: String): Boolean {
return address.any { it.isLetter() }
}

View file

@ -0,0 +1,205 @@
package org.fossify.messages.messaging
import android.annotation.SuppressLint
import android.app.Activity
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.Telephony.Sms
import android.telephony.SmsManager
import android.telephony.SmsMessage
import android.widget.Toast
import com.klinker.android.send_message.Message
import com.klinker.android.send_message.Settings
import com.klinker.android.send_message.Transaction
import org.fossify.commons.extensions.showErrorToast
import org.fossify.commons.extensions.toast
import org.fossify.messages.R
import org.fossify.messages.extensions.getThreadId
import org.fossify.messages.extensions.isPlainTextMimeType
import org.fossify.messages.extensions.smsSender
import org.fossify.messages.messaging.SmsException.Companion.ERROR_PERSISTING_MESSAGE
import org.fossify.messages.models.Attachment
import org.fossify.messages.receivers.MmsSentReceiver
import org.fossify.messages.receivers.SendStatusReceiver
class MessagingUtils(val context: Context) {
/**
* Insert an SMS to the given URI with thread_id specified.
*/
private fun insertSmsMessage(
subId: Int, dest: String, text: String, timestamp: Long, threadId: Long,
status: Int = Sms.STATUS_NONE, type: Int = Sms.MESSAGE_TYPE_OUTBOX, messageId: Long? = null
): Uri {
val response: Uri?
val values = ContentValues().apply {
put(Sms.ADDRESS, dest)
put(Sms.DATE, timestamp)
put(Sms.READ, 1)
put(Sms.SEEN, 1)
put(Sms.BODY, text)
// insert subscription id only if it is a valid one.
if (subId != Settings.DEFAULT_SUBSCRIPTION_ID) {
put(Sms.SUBSCRIPTION_ID, subId)
}
if (status != Sms.STATUS_NONE) {
put(Sms.STATUS, status)
}
if (type != Sms.MESSAGE_TYPE_ALL) {
put(Sms.TYPE, type)
}
if (threadId != -1L) {
put(Sms.THREAD_ID, threadId)
}
}
try {
if (messageId != null) {
val selection = "${Sms._ID} = ?"
val selectionArgs = arrayOf(messageId.toString())
val count = context.contentResolver.update(Sms.CONTENT_URI, values, selection, selectionArgs)
if (count > 0) {
response = Uri.parse("${Sms.CONTENT_URI}/${messageId}")
} else {
response = null
}
} else {
response = context.contentResolver.insert(Sms.CONTENT_URI, values)
}
} catch (e: Exception) {
throw SmsException(ERROR_PERSISTING_MESSAGE, e)
}
return response ?: throw SmsException(ERROR_PERSISTING_MESSAGE)
}
/** Send an SMS message given [text] and [addresses]. A [SmsException] is thrown in case any errors occur. */
fun sendSmsMessage(
text: String, addresses: Set<String>, subId: Int, requireDeliveryReport: Boolean, messageId: Long? = null
) {
if (addresses.size > 1) {
// insert a dummy message for this thread if it is a group message
val broadCastThreadId = context.getThreadId(addresses.toSet())
val mergedAddresses = addresses.joinToString(ADDRESS_SEPARATOR)
insertSmsMessage(
subId = subId, dest = mergedAddresses, text = text,
timestamp = System.currentTimeMillis(), threadId = broadCastThreadId,
status = Sms.Sent.STATUS_COMPLETE, type = Sms.Sent.MESSAGE_TYPE_SENT,
messageId = messageId
)
}
for (address in addresses) {
val threadId = context.getThreadId(address)
val messageUri = insertSmsMessage(
subId = subId, dest = address, text = text,
timestamp = System.currentTimeMillis(), threadId = threadId,
messageId = messageId
)
try {
context.smsSender.sendMessage(
subId = subId, destination = address, body = text, serviceCenter = null,
requireDeliveryReport = requireDeliveryReport, messageUri = messageUri
)
} catch (e: Exception) {
updateSmsMessageSendingStatus(messageUri, Sms.Outbox.MESSAGE_TYPE_FAILED)
throw e // propagate error to caller
}
}
}
fun updateSmsMessageSendingStatus(messageUri: Uri?, type: Int) {
val resolver = context.contentResolver
val values = ContentValues().apply {
put(Sms.Outbox.TYPE, type)
}
try {
if (messageUri != null) {
resolver.update(messageUri, values, null, null)
} else {
// mark latest sms as sent, need to check if this is still necessary (or reliable)
// as this was taken from android-smsmms. The messageUri shouldn't be null anyway
val cursor = resolver.query(Sms.Outbox.CONTENT_URI, null, null, null, null)
cursor?.use {
if (cursor.moveToFirst()) {
@SuppressLint("Range")
val id = cursor.getString(cursor.getColumnIndex(Sms.Outbox._ID))
val selection = "${Sms._ID} = ?"
val selectionArgs = arrayOf(id.toString())
resolver.update(Sms.Outbox.CONTENT_URI, values, selection, selectionArgs)
}
}
}
} catch (e: Exception) {
context.showErrorToast(e)
}
}
fun getSmsMessageFromDeliveryReport(intent: Intent): SmsMessage? {
val pdu = intent.getByteArrayExtra("pdu")
val format = intent.getStringExtra("format")
return SmsMessage.createFromPdu(pdu, format)
}
@Deprecated("TODO: Move/rewrite MMS code into the app.")
fun sendMmsMessage(text: String, addresses: List<String>, attachment: Attachment?, settings: Settings, messageId: Long? = null) {
val transaction = Transaction(context, settings)
val message = Message(text, addresses.toTypedArray())
if (attachment != null) {
try {
val uri = attachment.getUri()
context.contentResolver.openInputStream(uri)?.use {
val bytes = it.readBytes()
val mimeType = if (attachment.mimetype.isPlainTextMimeType()) {
"application/txt"
} else {
attachment.mimetype
}
val name = attachment.filename
message.addMedia(bytes, mimeType, name, name)
}
} catch (e: Exception) {
context.showErrorToast(e)
} catch (e: Error) {
context.showErrorToast(e.localizedMessage ?: context.getString(org.fossify.commons.R.string.unknown_error_occurred))
}
}
val mmsSentIntent = Intent(context, MmsSentReceiver::class.java)
mmsSentIntent.putExtra(MmsSentReceiver.EXTRA_ORIGINAL_RESENT_MESSAGE_ID, messageId)
transaction.setExplicitBroadcastForSentMms(mmsSentIntent)
try {
transaction.sendNewMessage(message)
} catch (e: Exception) {
context.showErrorToast(e)
}
}
fun maybeShowErrorToast(resultCode: Int, errorCode: Int) {
if (resultCode != Activity.RESULT_OK) {
val msg = if (errorCode != SendStatusReceiver.NO_ERROR_CODE) {
context.getString(R.string.carrier_send_error)
} else {
when (resultCode) {
SmsManager.RESULT_ERROR_NO_SERVICE -> context.getString(R.string.error_service_is_unavailable)
SmsManager.RESULT_ERROR_RADIO_OFF -> context.getString(R.string.error_radio_turned_off)
SmsManager.RESULT_NO_DEFAULT_SMS_APP -> context.getString(R.string.sim_card_not_available)
else -> context.getString(R.string.unknown_error_occurred_sending_message, resultCode)
}
}
context.toast(msg = msg, length = Toast.LENGTH_LONG)
} else {
// no-op
}
}
companion object {
const val ADDRESS_SEPARATOR = "|"
}
}

View file

@ -0,0 +1,38 @@
package org.fossify.messages.messaging
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.AlarmManagerCompat
import org.fossify.messages.helpers.SCHEDULED_MESSAGE_ID
import org.fossify.messages.helpers.THREAD_ID
import org.fossify.messages.models.Message
import org.fossify.messages.receivers.ScheduledMessageReceiver
/**
* All things related to scheduled messages are here.
*/
fun Context.getScheduleSendPendingIntent(message: Message): PendingIntent {
val intent = Intent(this, ScheduledMessageReceiver::class.java)
intent.putExtra(THREAD_ID, message.threadId)
intent.putExtra(SCHEDULED_MESSAGE_ID, message.id)
val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
return PendingIntent.getBroadcast(this, message.id.toInt(), intent, flags)
}
fun Context.scheduleMessage(message: Message) {
val pendingIntent = getScheduleSendPendingIntent(message)
val triggerAtMillis = message.millis()
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
AlarmManagerCompat.setExactAndAllowWhileIdle(alarmManager, AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent)
}
fun Context.cancelScheduleSendPendingIntent(messageId: Long) {
val intent = Intent(this, ScheduledMessageReceiver::class.java)
val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
PendingIntent.getBroadcast(this, messageId.toInt(), intent, flags).cancel()
}

View file

@ -0,0 +1,9 @@
package org.fossify.messages.messaging
class SmsException(val errorCode: Int, val exception: Exception? = null) : Exception() {
companion object {
const val EMPTY_DESTINATION_ADDRESS = -1
const val ERROR_PERSISTING_MESSAGE = -2
const val ERROR_SENDING_MESSAGE = -3
}
}

View file

@ -0,0 +1,25 @@
package org.fossify.messages.messaging
import android.telephony.SmsManager
import com.klinker.android.send_message.Settings
private var smsManagerInstance: SmsManager? = null
private var associatedSubId: Int = -1
@Suppress("DEPRECATION")
fun getSmsManager(subId: Int): SmsManager {
if (smsManagerInstance == null || subId != associatedSubId) {
smsManagerInstance = if (subId != Settings.DEFAULT_SUBSCRIPTION_ID) {
try {
smsManagerInstance = SmsManager.getSmsManagerForSubscriptionId(subId)
} catch (e: Exception) {
e.printStackTrace()
}
smsManagerInstance ?: SmsManager.getDefault()
} else {
SmsManager.getDefault()
}
associatedSubId = subId
}
return smsManagerInstance!!
}

View file

@ -0,0 +1,133 @@
package org.fossify.messages.messaging
import android.app.Application
import android.app.PendingIntent
import android.content.Intent
import android.net.Uri
import android.telephony.PhoneNumberUtils
import org.fossify.commons.helpers.isSPlus
import org.fossify.messages.messaging.SmsException.Companion.EMPTY_DESTINATION_ADDRESS
import org.fossify.messages.messaging.SmsException.Companion.ERROR_SENDING_MESSAGE
import org.fossify.messages.receivers.SendStatusReceiver
import org.fossify.messages.receivers.SmsStatusDeliveredReceiver
import org.fossify.messages.receivers.SmsStatusSentReceiver
/** Class that sends chat message via SMS. */
class SmsSender(val app: Application) {
// not sure what to do about this yet. this is the default as per android-smsmms
private val sendMultipartSmsAsSeparateMessages = false
// This should be called from a RequestWriter queue thread
fun sendMessage(
subId: Int, destination: String, body: String, serviceCenter: String?,
requireDeliveryReport: Boolean, messageUri: Uri
) {
var dest = destination
if (body.isEmpty()) {
throw IllegalArgumentException("SmsSender: empty text message")
}
// remove spaces and dashes from destination number
// (e.g. "801 555 1212" -> "8015551212")
// (e.g. "+8211-123-4567" -> "+82111234567")
dest = PhoneNumberUtils.stripSeparators(dest)
if (dest.isEmpty()) {
throw SmsException(EMPTY_DESTINATION_ADDRESS)
}
// Divide the input message by SMS length limit
val smsManager = getSmsManager(subId)
val messages = smsManager.divideMessage(body)
if (messages == null || messages.size < 1) {
throw SmsException(ERROR_SENDING_MESSAGE)
}
// Actually send the sms
sendInternal(
subId, dest, messages, serviceCenter, requireDeliveryReport, messageUri
)
}
// Actually sending the message using SmsManager
private fun sendInternal(
subId: Int, dest: String,
messages: ArrayList<String>, serviceCenter: String?,
requireDeliveryReport: Boolean, messageUri: Uri
) {
val smsManager = getSmsManager(subId)
val messageCount = messages.size
val deliveryIntents = ArrayList<PendingIntent?>(messageCount)
val sentIntents = ArrayList<PendingIntent>(messageCount)
var flags = PendingIntent.FLAG_UPDATE_CURRENT
if (isSPlus()) {
flags = flags or PendingIntent.FLAG_MUTABLE
}
for (i in 0 until messageCount) {
// Make pending intents different for each message part
val partId = if (messageCount <= 1) 0 else i + 1
if (requireDeliveryReport && i == messageCount - 1) {
deliveryIntents.add(
PendingIntent.getBroadcast(
app,
partId,
getDeliveredStatusIntent(messageUri, subId),
flags
)
)
} else {
deliveryIntents.add(null)
}
sentIntents.add(
PendingIntent.getBroadcast(
app,
partId,
getSendStatusIntent(messageUri, subId),
flags
)
)
}
try {
if (sendMultipartSmsAsSeparateMessages) {
// If multipart sms is not supported, send them as separate messages
for (i in 0 until messageCount) {
smsManager.sendTextMessage(
dest,
serviceCenter,
messages[i],
sentIntents[i],
deliveryIntents[i]
)
}
} else {
smsManager.sendMultipartTextMessage(
dest, serviceCenter, messages, sentIntents, deliveryIntents
)
}
} catch (e: Exception) {
throw SmsException(ERROR_SENDING_MESSAGE, e)
}
}
private fun getSendStatusIntent(requestUri: Uri, subId: Int): Intent {
val intent = Intent(SendStatusReceiver.SMS_SENT_ACTION, requestUri, app, SmsStatusSentReceiver::class.java)
intent.putExtra(SendStatusReceiver.EXTRA_SUB_ID, subId)
return intent
}
private fun getDeliveredStatusIntent(requestUri: Uri, subId: Int): Intent {
val intent = Intent(SendStatusReceiver.SMS_DELIVERED_ACTION, requestUri, app, SmsStatusDeliveredReceiver::class.java)
intent.putExtra(SendStatusReceiver.EXTRA_SUB_ID, subId)
return intent
}
companion object {
private var instance: SmsSender? = null
fun getInstance(app: Application): SmsSender {
if (instance == null) {
instance = SmsSender(app)
}
return instance!!
}
}
}

View file

@ -0,0 +1,15 @@
package org.fossify.messages.models
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "archived_conversations",
indices = [(Index(value = ["thread_id"], unique = true))]
)
data class ArchivedConversation(
@PrimaryKey @ColumnInfo(name = "thread_id") var threadId: Long,
@ColumnInfo(name = "deleted_ts") var deletedTs: Long
)

View file

@ -0,0 +1,21 @@
package org.fossify.messages.models
import android.net.Uri
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(tableName = "attachments", indices = [(Index(value = ["message_id"], unique = true))])
data class Attachment(
@PrimaryKey(autoGenerate = true) var id: Long?,
@ColumnInfo(name = "message_id") var messageId: Long,
@ColumnInfo(name = "uri_string") var uriString: String,
@ColumnInfo(name = "mimetype") var mimetype: String,
@ColumnInfo(name = "width") var width: Int,
@ColumnInfo(name = "height") var height: Int,
@ColumnInfo(name = "filename") var filename: String
) {
fun getUri() = Uri.parse(uriString)
}

View file

@ -0,0 +1,36 @@
package org.fossify.messages.models
import android.net.Uri
import org.fossify.messages.extensions.isImageMimeType
import org.fossify.messages.extensions.isVCardMimeType
import org.fossify.messages.extensions.isVideoMimeType
import org.fossify.messages.helpers.ATTACHMENT_DOCUMENT
import org.fossify.messages.helpers.ATTACHMENT_MEDIA
import org.fossify.messages.helpers.ATTACHMENT_VCARD
data class AttachmentSelection(
val id: String,
val uri: Uri,
val mimetype: String,
val filename: String,
var isPending: Boolean,
val viewType: Int = getViewTypeForMimeType(mimetype)
) {
companion object {
fun getViewTypeForMimeType(mimetype: String): Int {
return when {
mimetype.isImageMimeType() || mimetype.isVideoMimeType() -> ATTACHMENT_MEDIA
mimetype.isVCardMimeType() -> ATTACHMENT_VCARD
else -> ATTACHMENT_DOCUMENT
}
}
fun areItemsTheSame(first: AttachmentSelection, second: AttachmentSelection): Boolean {
return first.id == second.id
}
fun areContentsTheSame(first: AttachmentSelection, second: AttachmentSelection): Boolean {
return first.uri == second.uri && first.mimetype == second.mimetype && first.filename == second.filename
}
}
}

View file

@ -0,0 +1,13 @@
package org.fossify.messages.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
enum class BackupType {
@SerialName("sms")
SMS,
@SerialName("mms")
MMS,
}

View file

@ -0,0 +1,38 @@
package org.fossify.messages.models
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(tableName = "conversations", indices = [(Index(value = ["thread_id"], unique = true))])
data class Conversation(
@PrimaryKey @ColumnInfo(name = "thread_id") var threadId: Long,
@ColumnInfo(name = "snippet") var snippet: String,
@ColumnInfo(name = "date") var date: Int,
@ColumnInfo(name = "read") var read: Boolean,
@ColumnInfo(name = "title") var title: String,
@ColumnInfo(name = "photo_uri") var photoUri: String,
@ColumnInfo(name = "is_group_conversation") var isGroupConversation: Boolean,
@ColumnInfo(name = "phone_number") var phoneNumber: String,
@ColumnInfo(name = "is_scheduled") var isScheduled: Boolean = false,
@ColumnInfo(name = "uses_custom_title") var usesCustomTitle: Boolean = false,
@ColumnInfo(name = "archived") var isArchived: Boolean = false
) {
companion object {
fun areItemsTheSame(old: Conversation, new: Conversation): Boolean {
return old.threadId == new.threadId
}
fun areContentsTheSame(old: Conversation, new: Conversation): Boolean {
return old.snippet == new.snippet &&
old.date == new.date &&
old.read == new.read &&
old.title == new.title &&
old.photoUri == new.photoUri &&
old.isGroupConversation == new.isGroupConversation &&
old.phoneNumber == new.phoneNumber
}
}
}

View file

@ -0,0 +1,16 @@
package org.fossify.messages.models
import androidx.room.ColumnInfo
import androidx.room.Embedded
data class ConversationWithSnippetOverride(
@ColumnInfo(name = "new_snippet") val snippet: String?,
@Embedded val conversation: Conversation
) {
fun toConversation() =
if (snippet == null) {
conversation
} else {
conversation.copy(snippet = snippet)
}
}

View file

@ -0,0 +1,5 @@
package org.fossify.messages.models
class Events {
class RefreshMessages
}

View file

@ -0,0 +1,10 @@
package org.fossify.messages.models
import com.google.gson.annotations.SerializedName
data class ExportedMessage(
@SerializedName("sms")
val sms: List<SmsBackup>?,
@SerializedName("mms")
val mms: List<MmsBackup>?,
)

View file

@ -0,0 +1,5 @@
package org.fossify.messages.models
enum class ImportResult {
IMPORT_FAIL, IMPORT_OK, IMPORT_PARTIAL, IMPORT_NOTHING_NEW
}

View file

@ -0,0 +1,69 @@
package org.fossify.messages.models
import android.provider.Telephony
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import org.fossify.commons.models.SimpleContact
@Entity(tableName = "messages")
data class Message(
@PrimaryKey val id: Long,
@ColumnInfo(name = "body") val body: String,
@ColumnInfo(name = "type") val type: Int,
@ColumnInfo(name = "status") val status: Int,
@ColumnInfo(name = "participants") val participants: ArrayList<SimpleContact>,
@ColumnInfo(name = "date") val date: Int,
@ColumnInfo(name = "read") val read: Boolean,
@ColumnInfo(name = "thread_id") val threadId: Long,
@ColumnInfo(name = "is_mms") val isMMS: Boolean,
@ColumnInfo(name = "attachment") val attachment: MessageAttachment?,
@ColumnInfo(name = "sender_phone_number") val senderPhoneNumber: String,
@ColumnInfo(name = "sender_name") var senderName: String,
@ColumnInfo(name = "sender_photo_uri") val senderPhotoUri: String,
@ColumnInfo(name = "subscription_id") var subscriptionId: Int,
@ColumnInfo(name = "is_scheduled") var isScheduled: Boolean = false
) : ThreadItem() {
fun isReceivedMessage() = type == Telephony.Sms.MESSAGE_TYPE_INBOX
fun millis() = date * 1000L
fun getSender(): SimpleContact? =
participants.firstOrNull { it.doesHavePhoneNumber(senderPhoneNumber) }
?: participants.firstOrNull { it.name == senderName }
?: participants.firstOrNull()
companion object {
fun getStableId(message: Message): Long {
var result = message.id.hashCode()
result = 31 * result + message.body.hashCode()
result = 31 * result + message.date.hashCode()
result = 31 * result + message.threadId.hashCode()
result = 31 * result + message.isMMS.hashCode()
result = 31 * result + (message.attachment?.hashCode() ?: 0)
result = 31 * result + message.senderPhoneNumber.hashCode()
result = 31 * result + message.senderName.hashCode()
result = 31 * result + message.senderPhotoUri.hashCode()
result = 31 * result + message.isScheduled.hashCode()
return result.toLong()
}
fun areItemsTheSame(old: Message, new: Message): Boolean {
return old.id == new.id
}
fun areContentsTheSame(old: Message, new: Message): Boolean {
return old.body == new.body &&
old.threadId == new.threadId &&
old.date == new.date &&
old.isMMS == new.isMMS &&
old.attachment == new.attachment &&
old.senderPhoneNumber == new.senderPhoneNumber &&
old.senderName == new.senderName &&
old.senderPhotoUri == new.senderPhotoUri &&
old.isScheduled == new.isScheduled
}
}
}

View file

@ -0,0 +1,12 @@
package org.fossify.messages.models
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "message_attachments")
data class MessageAttachment(
@PrimaryKey val id: Long,
@ColumnInfo(name = "text") var text: String,
@ColumnInfo(name = "attachments") var attachments: ArrayList<Attachment>
)

View file

@ -0,0 +1,27 @@
package org.fossify.messages.models
import kotlinx.serialization.DeserializationStrategy
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.JsonContentPolymorphicSerializer
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
@Serializable(with = BackupSerializer::class)
sealed class MessagesBackup {
@SerialName("backupType")
abstract val backupType: BackupType
}
object BackupSerializer :
JsonContentPolymorphicSerializer<MessagesBackup>(MessagesBackup::class) {
override fun selectDeserializer(element: JsonElement): DeserializationStrategy<out MessagesBackup> {
return when (element.jsonObject["backupType"]?.jsonPrimitive?.content) {
"sms" -> SmsBackup.serializer()
"mms" -> MmsBackup.serializer()
else -> throw SerializationException("ERROR: No Serializer found. Serialization failed.")
}
}
}

View file

@ -0,0 +1,28 @@
package org.fossify.messages.models
import android.content.ContentValues
import android.provider.Telephony
import androidx.core.content.contentValuesOf
import com.google.gson.annotations.SerializedName
import kotlinx.serialization.Serializable
@Serializable
data class MmsAddress(
@SerializedName("address")
val address: String,
@SerializedName("type")
val type: Int,
@SerializedName("charset")
val charset: Int
) {
fun toContentValues(): ContentValues {
// msgId would be added at the point of insertion
// because it may have changed
return contentValuesOf(
Telephony.Mms.Addr.ADDRESS to address,
Telephony.Mms.Addr.TYPE to type,
Telephony.Mms.Addr.CHARSET to charset,
)
}
}

View file

@ -0,0 +1,73 @@
package org.fossify.messages.models
import android.content.ContentValues
import android.provider.Telephony
import androidx.core.content.contentValuesOf
import com.google.gson.annotations.SerializedName
import kotlinx.serialization.Serializable
@Serializable
data class MmsBackup(
@SerializedName("creator")
val creator: String?,
@SerializedName("ct_t")
val contentType: String?,
@SerializedName("d_rpt")
val deliveryReport: Int,
@SerializedName("date")
val date: Long,
@SerializedName("date_sent")
val dateSent: Long,
@SerializedName("locked")
val locked: Int,
@SerializedName("m_type")
val messageType: Int,
@SerializedName("msg_box")
val messageBox: Int,
@SerializedName("read")
val read: Int,
@SerializedName("rr")
val readReport: Int,
@SerializedName("seen")
val seen: Int,
@SerializedName("text_only")
val textOnly: Int,
@SerializedName("st")
val status: String?,
@SerializedName("sub")
val subject: String?,
@SerializedName("sub_cs")
val subjectCharSet: String?,
@SerializedName("sub_id")
val subscriptionId: Long,
@SerializedName("tr_id")
val transactionId: String?,
@SerializedName("addresses")
val addresses: List<MmsAddress>,
@SerializedName("parts")
val parts: List<MmsPart>,
override val backupType: BackupType = BackupType.MMS,
) : MessagesBackup() {
fun toContentValues(): ContentValues {
return contentValuesOf(
Telephony.Mms.TRANSACTION_ID to transactionId,
Telephony.Mms.SUBSCRIPTION_ID to subscriptionId,
Telephony.Mms.SUBJECT to subject,
Telephony.Mms.DATE to date,
Telephony.Mms.DATE_SENT to dateSent,
Telephony.Mms.LOCKED to locked,
Telephony.Mms.READ to read,
Telephony.Mms.STATUS to status,
Telephony.Mms.SUBJECT_CHARSET to subjectCharSet,
Telephony.Mms.SEEN to seen,
Telephony.Mms.MESSAGE_TYPE to messageType,
Telephony.Mms.MESSAGE_BOX to messageBox,
Telephony.Mms.DELIVERY_REPORT to deliveryReport,
Telephony.Mms.READ_REPORT to readReport,
Telephony.Mms.CONTENT_TYPE to contentType,
Telephony.Mms.TEXT_ONLY to textOnly,
)
}
}

View file

@ -0,0 +1,56 @@
package org.fossify.messages.models
import android.content.ContentValues
import android.provider.Telephony
import androidx.core.content.contentValuesOf
import com.google.gson.annotations.SerializedName
import kotlinx.serialization.Serializable
@Serializable
data class MmsPart(
@SerializedName("cd")
val contentDisposition: String?,
@SerializedName("chset")
val charset: String?,
@SerializedName("cid")
val contentId: String?,
@SerializedName("cl")
val contentLocation: String?,
@SerializedName("ct")
val contentType: String,
@SerializedName("ctt_s")
val ctStart: String?,
@SerializedName("ctt_t")
val ctType: String?,
@SerializedName("fn")
val filename: String?,
@SerializedName("name")
val name: String?,
@SerializedName("seq")
val sequenceOrder: Int,
@SerializedName("text")
val text: String?,
@SerializedName("data")
val data: String?,
) {
fun toContentValues(): ContentValues {
return contentValuesOf(
Telephony.Mms.Part.CONTENT_DISPOSITION to contentDisposition,
Telephony.Mms.Part.CHARSET to charset,
Telephony.Mms.Part.CONTENT_ID to contentId,
Telephony.Mms.Part.CONTENT_LOCATION to contentLocation,
Telephony.Mms.Part.CONTENT_TYPE to contentType,
Telephony.Mms.Part.CT_START to ctStart,
Telephony.Mms.Part.CT_TYPE to ctType,
Telephony.Mms.Part.FILENAME to filename,
Telephony.Mms.Part.NAME to name,
Telephony.Mms.Part.SEQ to sequenceOrder,
Telephony.Mms.Part.TEXT to text,
)
}
fun isNonText(): Boolean {
return !(text != null || contentType.lowercase().startsWith("text") || contentType.lowercase() == "application/smil")
}
}

View file

@ -0,0 +1,3 @@
package org.fossify.messages.models
data class NamePhoto(val name: String, val photoUri: String?)

View file

@ -0,0 +1,15 @@
package org.fossify.messages.models
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "recycle_bin_messages",
indices = [(Index(value = ["id"], unique = true))]
)
data class RecycleBinMessage(
@PrimaryKey val id: Long,
@ColumnInfo(name = "deleted_ts") var deletedTS: Long
)

View file

@ -0,0 +1,3 @@
package org.fossify.messages.models
data class SIMCard(val id: Int, val subscriptionId: Int, val label: String)

View file

@ -0,0 +1,3 @@
package org.fossify.messages.models
data class SearchResult(val messageId: Long, val title: String, val snippet: String, val date: String, val threadId: Long, var photoUri: String)

View file

@ -0,0 +1,53 @@
package org.fossify.messages.models
import android.content.ContentValues
import android.provider.Telephony
import androidx.core.content.contentValuesOf
import com.google.gson.annotations.SerializedName
import kotlinx.serialization.Serializable
@Serializable
data class SmsBackup(
@SerializedName("sub_id")
val subscriptionId: Long,
@SerializedName("address")
val address: String,
@SerializedName("body")
val body: String?,
@SerializedName("date")
val date: Long,
@SerializedName("date_sent")
val dateSent: Long,
@SerializedName("locked")
val locked: Int,
@SerializedName("protocol")
val protocol: String?,
@SerializedName("read")
val read: Int,
@SerializedName("status")
val status: Int,
@SerializedName("type")
val type: Int,
@SerializedName("service_center")
val serviceCenter: String?,
override val backupType: BackupType = BackupType.SMS,
) : MessagesBackup() {
fun toContentValues(): ContentValues {
return contentValuesOf(
Telephony.Sms.SUBSCRIPTION_ID to subscriptionId,
Telephony.Sms.ADDRESS to address,
Telephony.Sms.BODY to body,
Telephony.Sms.DATE to date,
Telephony.Sms.DATE_SENT to dateSent,
Telephony.Sms.LOCKED to locked,
Telephony.Sms.PROTOCOL to protocol,
Telephony.Sms.READ to read,
Telephony.Sms.STATUS to status,
Telephony.Sms.TYPE to type,
Telephony.Sms.SERVICE_CENTER to serviceCenter,
)
}
}

View file

@ -0,0 +1,12 @@
package org.fossify.messages.models
/**
* Thread item representations for the main thread recyclerview. [Message] is also a [ThreadItem]
*/
sealed class ThreadItem {
data class ThreadLoading(val id: Long) : ThreadItem()
data class ThreadDateTime(val date: Int, val simID: String) : ThreadItem()
data class ThreadError(val messageId: Long, val messageText: String) : ThreadItem()
data class ThreadSent(val messageId: Long, val delivered: Boolean) : ThreadItem()
data class ThreadSending(val messageId: Long) : ThreadItem()
}

View file

@ -0,0 +1,76 @@
package org.fossify.messages.models
import android.content.Context
import ezvcard.VCard
import ezvcard.property.*
import org.fossify.commons.extensions.normalizePhoneNumber
import org.fossify.messages.extensions.config
import org.fossify.messages.extensions.format
import org.fossify.messages.helpers.parseNameFromVCard
private val displayedPropertyClasses = arrayOf(
Telephone::class.java, Email::class.java, Organization::class.java, Birthday::class.java, Anniversary::class.java, Note::class.java
)
data class VCardWrapper(val vCard: VCard, val fullName: String?, val properties: List<VCardPropertyWrapper>, var expanded: Boolean = false) {
companion object {
fun from(context: Context, vCard: VCard): VCardWrapper {
val properties = vCard.properties
.filter { displayedPropertyClasses.contains(it::class.java) }
.map { VCardPropertyWrapper.from(context, it) }
.distinctBy { it.value }
val fullName = vCard.parseNameFromVCard()
return VCardWrapper(vCard, fullName, properties)
}
}
}
data class VCardPropertyWrapper(val value: String, val type: String, val property: VCardProperty) {
companion object {
private const val CELL = "CELL"
private const val HOME = "HOME"
private const val WORK = "WORK"
private fun VCardProperty.getPropertyTypeString(context: Context): String {
return when (parameters.type) {
CELL -> context.getString(org.fossify.commons.R.string.mobile)
HOME -> context.getString(org.fossify.commons.R.string.home)
WORK -> context.getString(org.fossify.commons.R.string.work)
else -> ""
}
}
fun from(context: Context, property: VCardProperty): VCardPropertyWrapper {
return property.run {
when (this) {
is Telephone -> VCardPropertyWrapper(text.normalizePhoneNumber(), getPropertyTypeString(context), property)
is Email -> VCardPropertyWrapper(value, getPropertyTypeString(context), property)
is Organization -> VCardPropertyWrapper(
value = values.joinToString(),
type = context.getString(org.fossify.commons.R.string.work),
property = property
)
is Birthday -> VCardPropertyWrapper(
value = date.format(context.config.dateFormat),
type = context.getString(org.fossify.commons.R.string.birthday),
property = property
)
is Anniversary -> VCardPropertyWrapper(
value = date.format(context.config.dateFormat),
type = context.getString(org.fossify.commons.R.string.anniversary),
property = property
)
is Note -> VCardPropertyWrapper(value, context.getString(org.fossify.commons.R.string.notes), property)
else -> VCardPropertyWrapper("", "", property)
}
}
}
}
}

View file

@ -0,0 +1,31 @@
package org.fossify.messages.receivers
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import org.fossify.commons.extensions.notificationManager
import org.fossify.commons.helpers.ensureBackgroundThread
import org.fossify.messages.extensions.conversationsDB
import org.fossify.messages.extensions.deleteMessage
import org.fossify.messages.extensions.updateLastConversationMessage
import org.fossify.messages.extensions.updateUnreadCountBadge
import org.fossify.messages.helpers.IS_MMS
import org.fossify.messages.helpers.MESSAGE_ID
import org.fossify.messages.helpers.THREAD_ID
import org.fossify.messages.helpers.refreshMessages
class DeleteSmsReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val threadId = intent.getLongExtra(THREAD_ID, 0L)
val messageId = intent.getLongExtra(MESSAGE_ID, 0L)
val isMms = intent.getBooleanExtra(IS_MMS, false)
context.notificationManager.cancel(threadId.hashCode())
ensureBackgroundThread {
context.deleteMessage(messageId, isMms)
context.updateUnreadCountBadge(context.conversationsDB.getUnreadConversations())
context.updateLastConversationMessage(threadId)
refreshMessages()
}
}
}

View file

@ -0,0 +1,65 @@
package org.fossify.messages.receivers
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Handler
import android.os.Looper
import androidx.core.app.RemoteInput
import org.fossify.commons.extensions.showErrorToast
import org.fossify.commons.helpers.SimpleContactsHelper
import org.fossify.commons.helpers.ensureBackgroundThread
import org.fossify.messages.extensions.*
import org.fossify.messages.helpers.REPLY
import org.fossify.messages.helpers.THREAD_ID
import org.fossify.messages.helpers.THREAD_NUMBER
import org.fossify.messages.messaging.sendMessageCompat
class DirectReplyReceiver : BroadcastReceiver() {
@SuppressLint("MissingPermission")
override fun onReceive(context: Context, intent: Intent) {
val address = intent.getStringExtra(THREAD_NUMBER)
val threadId = intent.getLongExtra(THREAD_ID, 0L)
var body = RemoteInput.getResultsFromIntent(intent)?.getCharSequence(REPLY)?.toString() ?: return
body = context.removeDiacriticsIfNeeded(body)
if (address != null) {
var subscriptionId: Int? = null
val availableSIMs = context.subscriptionManagerCompat().activeSubscriptionInfoList
if ((availableSIMs?.size ?: 0) > 1) {
val currentSIMCardIndex = context.config.getUseSIMIdAtNumber(address)
val wantedId = availableSIMs.getOrNull(currentSIMCardIndex)
if (wantedId != null) {
subscriptionId = wantedId.subscriptionId
}
}
ensureBackgroundThread {
var messageId = 0L
try {
context.sendMessageCompat(body, listOf(address), subscriptionId, emptyList())
val message = context.getMessages(threadId, getImageResolutions = false, includeScheduledMessages = false, limit = 1).lastOrNull()
if (message != null) {
context.messagesDB.insertOrUpdate(message)
messageId = message.id
context.updateLastConversationMessage(threadId)
}
} catch (e: Exception) {
context.showErrorToast(e)
}
val photoUri = SimpleContactsHelper(context).getPhotoUriFromPhoneNumber(address)
val bitmap = context.getNotificationBitmap(photoUri)
Handler(Looper.getMainLooper()).post {
context.notificationHelper.showMessageNotification(messageId, address, body, threadId, bitmap, sender = null, alertOnlyOnce = true)
}
context.markThreadMessagesRead(threadId)
context.conversationsDB.markRead(threadId)
}
}
}
}

View file

@ -0,0 +1,30 @@
package org.fossify.messages.receivers
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import org.fossify.commons.extensions.notificationManager
import org.fossify.commons.helpers.ensureBackgroundThread
import org.fossify.messages.extensions.conversationsDB
import org.fossify.messages.extensions.markThreadMessagesRead
import org.fossify.messages.extensions.updateUnreadCountBadge
import org.fossify.messages.helpers.MARK_AS_READ
import org.fossify.messages.helpers.THREAD_ID
import org.fossify.messages.helpers.refreshMessages
class MarkAsReadReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
MARK_AS_READ -> {
val threadId = intent.getLongExtra(THREAD_ID, 0L)
context.notificationManager.cancel(threadId.hashCode())
ensureBackgroundThread {
context.markThreadMessagesRead(threadId)
context.conversationsDB.markRead(threadId)
context.updateUnreadCountBadge(context.conversationsDB.getUnreadConversations())
refreshMessages()
}
}
}
}
}

View file

@ -0,0 +1,55 @@
package org.fossify.messages.receivers
import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.Looper
import com.bumptech.glide.Glide
import com.klinker.android.send_message.MmsReceivedReceiver
import org.fossify.commons.extensions.isNumberBlocked
import org.fossify.commons.extensions.normalizePhoneNumber
import org.fossify.commons.extensions.showErrorToast
import org.fossify.commons.helpers.ensureBackgroundThread
import org.fossify.messages.R
import org.fossify.messages.extensions.*
import org.fossify.messages.helpers.refreshMessages
// more info at https://github.com/klinker41/android-smsmms
class MmsReceiver : MmsReceivedReceiver() {
override fun isAddressBlocked(context: Context, address: String): Boolean {
val normalizedAddress = address.normalizePhoneNumber()
return context.isNumberBlocked(normalizedAddress)
}
override fun onMessageReceived(context: Context, messageUri: Uri) {
val mms = context.getLatestMMS() ?: return
val address = mms.getSender()?.phoneNumbers?.first()?.normalizedNumber ?: ""
val size = context.resources.getDimension(R.dimen.notification_large_icon_size).toInt()
ensureBackgroundThread {
val glideBitmap = try {
Glide.with(context)
.asBitmap()
.load(mms.attachment!!.attachments.first().getUri())
.centerCrop()
.into(size, size)
.get()
} catch (e: Exception) {
null
}
Handler(Looper.getMainLooper()).post {
context.showReceivedMessageNotification(mms.id, address, mms.body, mms.threadId, glideBitmap)
val conversation = context.getConversations(mms.threadId).firstOrNull() ?: return@post
ensureBackgroundThread {
context.insertOrUpdateConversation(conversation)
context.updateUnreadCountBadge(context.conversationsDB.getUnreadConversations())
refreshMessages()
}
}
}
}
override fun onError(context: Context, error: String) = context.showErrorToast(context.getString(R.string.couldnt_download_mms))
}

View file

@ -0,0 +1,62 @@
package org.fossify.messages.receivers
import android.app.Activity
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.database.sqlite.SQLiteException
import android.net.Uri
import android.provider.Telephony
import android.widget.Toast
import org.fossify.commons.extensions.showErrorToast
import org.fossify.commons.extensions.toast
import org.fossify.messages.R
import org.fossify.messages.extensions.deleteMessage
import org.fossify.messages.helpers.refreshMessages
import java.io.File
/** Handles updating databases and states when a MMS message is sent. */
class MmsSentReceiver : SendStatusReceiver() {
override fun updateAndroidDatabase(context: Context, intent: Intent, receiverResultCode: Int) {
val uri = Uri.parse(intent.getStringExtra(EXTRA_CONTENT_URI))
val originalResentMessageId = intent.getLongExtra(EXTRA_ORIGINAL_RESENT_MESSAGE_ID, -1L)
val messageBox = if (receiverResultCode == Activity.RESULT_OK) {
Telephony.Mms.MESSAGE_BOX_SENT
} else {
val msg = context.getString(R.string.unknown_error_occurred_sending_message, receiverResultCode)
context.toast(msg = msg, length = Toast.LENGTH_LONG)
Telephony.Mms.MESSAGE_BOX_FAILED
}
val values = ContentValues(1).apply {
put(Telephony.Mms.MESSAGE_BOX, messageBox)
}
try {
context.contentResolver.update(uri, values, null, null)
} catch (e: SQLiteException) {
context.showErrorToast(e)
}
// In case of resent message, delete original to prevent duplication
if (originalResentMessageId != -1L) {
context.deleteMessage(originalResentMessageId, true)
}
val filePath = intent.getStringExtra(EXTRA_FILE_PATH)
if (filePath != null) {
File(filePath).delete()
}
}
override fun updateAppDatabase(context: Context, intent: Intent, receiverResultCode: Int) {
refreshMessages()
}
companion object {
private const val EXTRA_CONTENT_URI = "content_uri"
private const val EXTRA_FILE_PATH = "file_path"
const val EXTRA_ORIGINAL_RESENT_MESSAGE_ID = "original_message_id"
}
}

View file

@ -0,0 +1,61 @@
package org.fossify.messages.receivers
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import org.fossify.commons.extensions.showErrorToast
import org.fossify.commons.helpers.ensureBackgroundThread
import org.fossify.messages.extensions.conversationsDB
import org.fossify.messages.extensions.deleteScheduledMessage
import org.fossify.messages.extensions.getAddresses
import org.fossify.messages.extensions.messagesDB
import org.fossify.messages.helpers.SCHEDULED_MESSAGE_ID
import org.fossify.messages.helpers.THREAD_ID
import org.fossify.messages.helpers.refreshMessages
import org.fossify.messages.messaging.sendMessageCompat
class ScheduledMessageReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
val wakelock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "simple.messenger:scheduled.message.receiver")
wakelock.acquire(3000)
ensureBackgroundThread {
handleIntent(context, intent)
}
}
private fun handleIntent(context: Context, intent: Intent) {
val threadId = intent.getLongExtra(THREAD_ID, 0L)
val messageId = intent.getLongExtra(SCHEDULED_MESSAGE_ID, 0L)
val message = try {
context.messagesDB.getScheduledMessageWithId(threadId, messageId)
} catch (e: Exception) {
e.printStackTrace()
return
}
val addresses = message.participants.getAddresses()
val attachments = message.attachment?.attachments ?: emptyList()
try {
Handler(Looper.getMainLooper()).post {
context.sendMessageCompat(message.body, addresses, message.subscriptionId, attachments)
}
// delete temporary conversation and message as it's already persisted to the telephony db now
context.deleteScheduledMessage(messageId)
context.conversationsDB.deleteThreadId(messageId)
refreshMessages()
} catch (e: Exception) {
context.showErrorToast(e)
} catch (e: Error) {
context.showErrorToast(e.localizedMessage ?: context.getString(org.fossify.commons.R.string.unknown_error_occurred))
}
}
}

View file

@ -0,0 +1,33 @@
package org.fossify.messages.receivers
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import org.fossify.commons.helpers.ensureBackgroundThread
abstract class SendStatusReceiver : BroadcastReceiver() {
// Updates the status of the message in the internal database
abstract fun updateAndroidDatabase(context: Context, intent: Intent, receiverResultCode: Int)
// allows the implementer to update the status of the message in their database
abstract fun updateAppDatabase(context: Context, intent: Intent, receiverResultCode: Int)
override fun onReceive(context: Context, intent: Intent) {
val resultCode = resultCode
ensureBackgroundThread {
updateAndroidDatabase(context, intent, resultCode)
updateAppDatabase(context, intent, resultCode)
}
}
companion object {
const val SMS_SENT_ACTION = "org.fossify.org.fossify.messages.receiver.SMS_SENT"
const val SMS_DELIVERED_ACTION = "org.fossify.org.fossify.messages.receiver.SMS_DELIVERED"
// Defined by platform, but no constant provided. See docs for SmsManager.sendTextMessage.
const val EXTRA_ERROR_CODE = "errorCode"
const val EXTRA_SUB_ID = "subId"
const val NO_ERROR_CODE = -1
}
}

View file

@ -0,0 +1,135 @@
package org.fossify.messages.receivers
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Handler
import android.os.Looper
import android.provider.Telephony
import org.fossify.commons.extensions.baseConfig
import org.fossify.commons.extensions.getMyContactsCursor
import org.fossify.commons.extensions.isNumberBlocked
import org.fossify.commons.helpers.SimpleContactsHelper
import org.fossify.commons.helpers.ensureBackgroundThread
import org.fossify.commons.models.PhoneNumber
import org.fossify.commons.models.SimpleContact
import org.fossify.messages.extensions.*
import org.fossify.messages.helpers.refreshMessages
import org.fossify.messages.models.Message
class SmsReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val messages = Telephony.Sms.Intents.getMessagesFromIntent(intent)
var address = ""
var body = ""
var subject = ""
var date = 0L
var threadId = 0L
var status = Telephony.Sms.STATUS_NONE
val type = Telephony.Sms.MESSAGE_TYPE_INBOX
val read = 0
val subscriptionId = intent.getIntExtra("subscription", -1)
val privateCursor = context.getMyContactsCursor(false, true)
ensureBackgroundThread {
messages.forEach {
address = it.originatingAddress ?: ""
subject = it.pseudoSubject
status = it.status
body += it.messageBody
date = System.currentTimeMillis()
threadId = context.getThreadId(address)
}
if (context.baseConfig.blockUnknownNumbers) {
val simpleContactsHelper = SimpleContactsHelper(context)
simpleContactsHelper.exists(address, privateCursor) { exists ->
if (exists) {
handleMessage(context, address, subject, body, date, read, threadId, type, subscriptionId, status)
}
}
} else {
handleMessage(context, address, subject, body, date, read, threadId, type, subscriptionId, status)
}
}
}
private fun handleMessage(
context: Context,
address: String,
subject: String,
body: String,
date: Long,
read: Int,
threadId: Long,
type: Int,
subscriptionId: Int,
status: Int
) {
if (isMessageFilteredOut(context, body)) {
return
}
val photoUri = SimpleContactsHelper(context).getPhotoUriFromPhoneNumber(address)
val bitmap = context.getNotificationBitmap(photoUri)
Handler(Looper.getMainLooper()).post {
if (!context.isNumberBlocked(address)) {
val privateCursor = context.getMyContactsCursor(favoritesOnly = false, withPhoneNumbersOnly = true)
ensureBackgroundThread {
val newMessageId = context.insertNewSMS(address, subject, body, date, read, threadId, type, subscriptionId)
val conversation = context.getConversations(threadId).firstOrNull() ?: return@ensureBackgroundThread
try {
context.insertOrUpdateConversation(conversation)
} catch (ignored: Exception) {
}
try {
context.updateUnreadCountBadge(context.conversationsDB.getUnreadConversations())
} catch (ignored: Exception) {
}
val senderName = context.getNameFromAddress(address, privateCursor)
val phoneNumber = PhoneNumber(address, 0, "", address)
val participant = SimpleContact(0, 0, senderName, photoUri, arrayListOf(phoneNumber), ArrayList(), ArrayList())
val participants = arrayListOf(participant)
val messageDate = (date / 1000).toInt()
val message =
Message(
newMessageId,
body,
type,
status,
participants,
messageDate,
false,
threadId,
false,
null,
address,
senderName,
photoUri,
subscriptionId
)
context.messagesDB.insertOrUpdate(message)
if (context.config.isArchiveAvailable) {
context.updateConversationArchivedStatus(threadId, false)
}
refreshMessages()
context.showReceivedMessageNotification(newMessageId, address, body, threadId, bitmap)
}
}
}
}
private fun isMessageFilteredOut(context: Context, body: String): Boolean {
for (blockedKeyword in context.config.blockedKeywords) {
if (body.contains(blockedKeyword, ignoreCase = true)) {
return true
}
}
return false
}
}

View file

@ -0,0 +1,100 @@
package org.fossify.messages.receivers
import android.annotation.SuppressLint
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.Telephony.Sms
import org.fossify.commons.helpers.ensureBackgroundThread
import org.fossify.messages.extensions.messagesDB
import org.fossify.messages.extensions.messagingUtils
import org.fossify.messages.helpers.refreshMessages
/** Handles updating databases and states when a sent SMS message is delivered. */
class SmsStatusDeliveredReceiver : SendStatusReceiver() {
private var status: Int = Sms.Sent.STATUS_NONE
override fun updateAndroidDatabase(context: Context, intent: Intent, receiverResultCode: Int) {
val messageUri: Uri? = intent.data
val smsMessage = context.messagingUtils.getSmsMessageFromDeliveryReport(intent) ?: return
try {
val format = intent.getStringExtra("format")
status = smsMessage.status
// Simple matching up CDMA status with GSM status.
if ("3gpp2" == format) {
val errorClass = status shr 24 and 0x03
val statusCode = status shr 16 and 0x3f
status = when (errorClass) {
0 -> {
if (statusCode == 0x02 /*STATUS_DELIVERED*/) {
Sms.STATUS_COMPLETE
} else {
Sms.STATUS_PENDING
}
}
2 -> {
// TODO: Need to check whether SC still trying to deliver the SMS to destination and will send the report again?
Sms.STATUS_PENDING
}
3 -> {
Sms.STATUS_FAILED
}
else -> {
Sms.STATUS_PENDING
}
}
}
} catch (e: NullPointerException) {
// Sometimes, SmsMessage.mWrappedSmsMessage is null causing NPE when we access
// the methods on it although the SmsMessage itself is not null.
return
}
updateSmsStatusAndDateSent(context, messageUri, System.currentTimeMillis())
}
private fun updateSmsStatusAndDateSent(context: Context, messageUri: Uri?, timeSentInMillis: Long = -1L) {
val resolver = context.contentResolver
val values = ContentValues().apply {
if (status != Sms.Sent.STATUS_NONE) {
put(Sms.Sent.STATUS, status)
}
put(Sms.Sent.DATE_SENT, timeSentInMillis)
}
if (messageUri != null) {
resolver.update(messageUri, values, null, null)
} else {
// mark latest sms as delivered, need to check if this is still necessary (or reliable)
val cursor = resolver.query(Sms.Sent.CONTENT_URI, null, null, null, "date desc")
cursor?.use {
if (cursor.moveToFirst()) {
@SuppressLint("Range")
val id = cursor.getString(cursor.getColumnIndex(Sms.Sent._ID))
val selection = "${Sms._ID} = ?"
val selectionArgs = arrayOf(id.toString())
resolver.update(Sms.Sent.CONTENT_URI, values, selection, selectionArgs)
}
}
}
}
override fun updateAppDatabase(context: Context, intent: Intent, receiverResultCode: Int) {
val messageUri: Uri? = intent.data
if (messageUri != null) {
val messageId = messageUri.lastPathSegment?.toLong() ?: 0L
ensureBackgroundThread {
if (status != Sms.Sent.STATUS_NONE) {
context.messagesDB.updateStatus(messageId, status)
}
refreshMessages()
}
}
}
}

View file

@ -0,0 +1,69 @@
package org.fossify.messages.receivers
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.provider.Telephony.Sms
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import org.fossify.commons.extensions.getMyContactsCursor
import org.fossify.commons.helpers.ensureBackgroundThread
import org.fossify.messages.extensions.*
import org.fossify.messages.helpers.refreshMessages
/** Handles updating databases and states when a SMS message is sent. */
class SmsStatusSentReceiver : SendStatusReceiver() {
override fun updateAndroidDatabase(context: Context, intent: Intent, receiverResultCode: Int) {
val messageUri: Uri? = intent.data
val resultCode = resultCode
val messagingUtils = context.messagingUtils
val type = if (resultCode == Activity.RESULT_OK) {
Sms.MESSAGE_TYPE_SENT
} else {
Sms.MESSAGE_TYPE_FAILED
}
messagingUtils.updateSmsMessageSendingStatus(messageUri, type)
messagingUtils.maybeShowErrorToast(
resultCode = resultCode,
errorCode = intent.getIntExtra(EXTRA_ERROR_CODE, NO_ERROR_CODE)
)
}
override fun updateAppDatabase(context: Context, intent: Intent, receiverResultCode: Int) {
val messageUri = intent.data
if (messageUri != null) {
val messageId = messageUri.lastPathSegment?.toLong() ?: 0L
ensureBackgroundThread {
val type = if (receiverResultCode == Activity.RESULT_OK) {
Sms.MESSAGE_TYPE_SENT
} else {
showSendingFailedNotification(context, messageId)
Sms.MESSAGE_TYPE_FAILED
}
context.messagesDB.updateType(messageId, type)
refreshMessages()
}
}
}
private fun showSendingFailedNotification(context: Context, messageId: Long) {
Handler(Looper.getMainLooper()).post {
if (ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
return@post
}
val privateCursor = context.getMyContactsCursor(favoritesOnly = false, withPhoneNumbersOnly = true)
ensureBackgroundThread {
val address = context.getMessageRecipientAddress(messageId)
val threadId = context.getThreadId(address)
val recipientName = context.getNameFromAddress(address, privateCursor)
context.notificationHelper.showSendingFailedNotification(recipientName, threadId)
}
}
}
}

Some files were not shown because too many files have changed in this diff Show more