Rename package to org.fossify.messages
This commit is contained in:
parent
d71db351ca
commit
e2f83f49da
106 changed files with 417 additions and 418 deletions
11
app/src/main/kotlin/org/fossify/messages/App.kt
Normal file
11
app/src/main/kotlin/org/fossify/messages/App.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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`)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()}")
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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)
|
||||
1141
app/src/main/kotlin/org/fossify/messages/extensions/Context.kt
Normal file
1141
app/src/main/kotlin/org/fossify/messages/extensions/Context.kt
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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"
|
||||
}
|
||||
18
app/src/main/kotlin/org/fossify/messages/extensions/View.kt
Normal file
18
app/src/main/kotlin/org/fossify/messages/extensions/View.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
130
app/src/main/kotlin/org/fossify/messages/helpers/Config.kt
Normal file
130
app/src/main/kotlin/org/fossify/messages/helpers/Config.kt
Normal 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())
|
||||
}
|
||||
}
|
||||
101
app/src/main/kotlin/org/fossify/messages/helpers/Constants.kt
Normal file
101
app/src/main/kotlin/org/fossify/messages/helpers/Constants.kt
Normal 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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
|
|
@ -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 = "|"
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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!!
|
||||
}
|
||||
133
app/src/main/kotlin/org/fossify/messages/messaging/SmsSender.kt
Normal file
133
app/src/main/kotlin/org/fossify/messages/messaging/SmsSender.kt
Normal 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!!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package org.fossify.messages.models
|
||||
|
||||
class Events {
|
||||
class RefreshMessages
|
||||
}
|
||||
|
|
@ -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>?,
|
||||
)
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package org.fossify.messages.models
|
||||
|
||||
enum class ImportResult {
|
||||
IMPORT_FAIL, IMPORT_OK, IMPORT_PARTIAL, IMPORT_NOTHING_NEW
|
||||
}
|
||||
69
app/src/main/kotlin/org/fossify/messages/models/Message.kt
Normal file
69
app/src/main/kotlin/org/fossify/messages/models/Message.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
73
app/src/main/kotlin/org/fossify/messages/models/MmsBackup.kt
Normal file
73
app/src/main/kotlin/org/fossify/messages/models/MmsBackup.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
56
app/src/main/kotlin/org/fossify/messages/models/MmsPart.kt
Normal file
56
app/src/main/kotlin/org/fossify/messages/models/MmsPart.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package org.fossify.messages.models
|
||||
|
||||
data class NamePhoto(val name: String, val photoUri: String?)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package org.fossify.messages.models
|
||||
|
||||
data class SIMCard(val id: Int, val subscriptionId: Int, val label: String)
|
||||
|
|
@ -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)
|
||||
53
app/src/main/kotlin/org/fossify/messages/models/SmsBackup.kt
Normal file
53
app/src/main/kotlin/org/fossify/messages/models/SmsBackup.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
76
app/src/main/kotlin/org/fossify/messages/models/VCard.kt
Normal file
76
app/src/main/kotlin/org/fossify/messages/models/VCard.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue