feat: add conversation shortcuts (#280)

* Declare intent filter to open ThreadActivity from shortcut

* Add SimpleContact utilities

* Create the ShortcutHelper class

* update shortcuts on sending message, notification received and when opening thread.

* format code

* Avoid error when getConversations is called from UI thread

* Changed ranking of create new conversation shortcut to 0

* removed exception handling

* Run shortcut registration in background

* Changed shortcut creation and usage report

* do not create shortcut on opening conversation

* optimize imports

* Delete shortcut with conversation

* Show main activity if conversation does not exist

* removed old intent filter

* Specify Fossify thread activity in shortcut's intent

* Avoid dismissing activity if it's a new conversation

* Try to fix private contacts appearing as numbers

* Removed intent sanitizer since activity isn't exported anymore

* Update shortcut label and picture when changing conversation name

* refactor: cleanup code

* refactor: collapse empty tag

---------

Co-authored-by: Naveen Singh <36371707+naveensingh@users.noreply.github.com>
Co-authored-by: Naveen Singh <snaveen935@gmail.com>
This commit is contained in:
Vivien F 2025-06-04 10:45:01 +02:00 committed by GitHub
parent 0b7434568b
commit 857a1fd445
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 265 additions and 5 deletions

View file

@ -531,6 +531,7 @@ class MainActivity : SimpleActivity() {
.setLongLabel(newEvent) .setLongLabel(newEvent)
.setIcon(Icon.createWithBitmap(bmp)) .setIcon(Icon.createWithBitmap(bmp))
.setIntent(intent) .setIntent(intent)
.setRank(0)
.build() .build()
} }

View file

@ -151,6 +151,7 @@ import org.fossify.messages.helpers.CAPTURE_AUDIO_INTENT
import org.fossify.messages.helpers.CAPTURE_PHOTO_INTENT import org.fossify.messages.helpers.CAPTURE_PHOTO_INTENT
import org.fossify.messages.helpers.CAPTURE_VIDEO_INTENT import org.fossify.messages.helpers.CAPTURE_VIDEO_INTENT
import org.fossify.messages.helpers.FILE_SIZE_NONE import org.fossify.messages.helpers.FILE_SIZE_NONE
import org.fossify.messages.helpers.IS_LAUNCHED_FROM_SHORTCUT
import org.fossify.messages.helpers.IS_RECYCLE_BIN import org.fossify.messages.helpers.IS_RECYCLE_BIN
import org.fossify.messages.helpers.MESSAGES_LIMIT import org.fossify.messages.helpers.MESSAGES_LIMIT
import org.fossify.messages.helpers.PICK_CONTACT_INTENT import org.fossify.messages.helpers.PICK_CONTACT_INTENT
@ -192,7 +193,6 @@ import org.joda.time.DateTime
import java.io.File import java.io.File
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import kotlin.collections.set
class ThreadActivity : SimpleActivity() { class ThreadActivity : SimpleActivity() {
private val MIN_DATE_TIME_DIFF_SECS = 300 private val MIN_DATE_TIME_DIFF_SECS = 300
@ -220,6 +220,7 @@ class ThreadActivity : SimpleActivity() {
private var allMessagesFetched = false private var allMessagesFetched = false
private var oldestMessageDate = -1 private var oldestMessageDate = -1
private var isRecycleBin = false private var isRecycleBin = false
private var isLaunchedFromShortcut = false
private var isScheduledMessage: Boolean = false private var isScheduledMessage: Boolean = false
private var messageToResend: Long? = null private var messageToResend: Long? = null
@ -263,6 +264,7 @@ class ThreadActivity : SimpleActivity() {
binding.threadToolbar.title = it binding.threadToolbar.title = it
} }
isRecycleBin = intent.getBooleanExtra(IS_RECYCLE_BIN, false) isRecycleBin = intent.getBooleanExtra(IS_RECYCLE_BIN, false)
isLaunchedFromShortcut = intent.getBooleanExtra(IS_LAUNCHED_FROM_SHORTCUT, false)
bus = EventBus.getDefault() bus = EventBus.getDefault()
bus!!.register(this) bus!!.register(this)
@ -463,6 +465,16 @@ class ThreadActivity : SimpleActivity() {
} }
private fun setupThread() { private fun setupThread() {
if (conversation == null && isLaunchedFromShortcut) {
if (isTaskRoot) {
Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
startActivity(this)
}
}
finish()
return
}
val privateCursor = getMyContactsCursor(favoritesOnly = false, withPhoneNumbersOnly = true) val privateCursor = getMyContactsCursor(favoritesOnly = false, withPhoneNumbersOnly = true)
ensureBackgroundThread { ensureBackgroundThread {
privateContacts = MyContactsContentProvider.getSimpleContacts(this, privateCursor) privateContacts = MyContactsContentProvider.getSimpleContacts(this, privateCursor)

View file

@ -39,7 +39,6 @@ import org.fossify.commons.extensions.normalizeString
import org.fossify.commons.extensions.notificationManager import org.fossify.commons.extensions.notificationManager
import org.fossify.commons.extensions.queryCursor import org.fossify.commons.extensions.queryCursor
import org.fossify.commons.extensions.showErrorToast import org.fossify.commons.extensions.showErrorToast
import org.fossify.commons.extensions.toInt
import org.fossify.commons.extensions.toast import org.fossify.commons.extensions.toast
import org.fossify.commons.extensions.trimToComparableNumber import org.fossify.commons.extensions.trimToComparableNumber
import org.fossify.commons.helpers.DAY_SECONDS import org.fossify.commons.helpers.DAY_SECONDS
@ -59,6 +58,7 @@ import org.fossify.messages.helpers.FILE_SIZE_NONE
import org.fossify.messages.helpers.MAX_MESSAGE_LENGTH import org.fossify.messages.helpers.MAX_MESSAGE_LENGTH
import org.fossify.messages.helpers.MESSAGES_LIMIT import org.fossify.messages.helpers.MESSAGES_LIMIT
import org.fossify.messages.helpers.NotificationHelper import org.fossify.messages.helpers.NotificationHelper
import org.fossify.messages.helpers.ShortcutHelper
import org.fossify.messages.helpers.generateRandomId import org.fossify.messages.helpers.generateRandomId
import org.fossify.messages.interfaces.AttachmentsDao import org.fossify.messages.interfaces.AttachmentsDao
import org.fossify.messages.interfaces.ConversationsDao import org.fossify.messages.interfaces.ConversationsDao
@ -107,6 +107,8 @@ val Context.messagingUtils
val Context.smsSender val Context.smsSender
get() = SmsSender.getInstance(applicationContext as Application) get() = SmsSender.getInstance(applicationContext as Application)
val Context.shortcutHelper get() = ShortcutHelper(this)
fun Context.getMessages( fun Context.getMessages(
threadId: Long, threadId: Long,
getImageResolutions: Boolean, getImageResolutions: Boolean,
@ -855,6 +857,9 @@ fun Context.deleteConversation(threadId: Long) {
config.removeCustomNotificationsByThreadId(threadId) config.removeCustomNotificationsByThreadId(threadId)
notificationManager.deleteNotificationChannel(threadId.toString()) notificationManager.deleteNotificationChannel(threadId.toString())
} }
if(shortcutHelper.getShortcut(threadId) != null) {
shortcutHelper.removeShortcutForThread(threadId)
}
} }
fun Context.checkAndDeleteOldRecycleBinMessages(callback: (() -> Unit)? = null) { fun Context.checkAndDeleteOldRecycleBinMessages(callback: (() -> Unit)? = null) {
@ -1253,6 +1258,9 @@ fun Context.renameConversation(conversation: Conversation, newTitle: String): Co
val updatedConv = conversation.copy(title = newTitle, usesCustomTitle = true) val updatedConv = conversation.copy(title = newTitle, usesCustomTitle = true)
try { try {
conversationsDB.insertOrUpdate(updatedConv) conversationsDB.insertOrUpdate(updatedConv)
ensureBackgroundThread {
shortcutHelper.createOrUpdateShortcut(updatedConv)
}
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
} }

View file

@ -1,8 +1,52 @@
package org.fossify.messages.extensions package org.fossify.messages.extensions
import android.content.Context
import android.graphics.BitmapFactory
import android.net.Uri
import android.provider.ContactsContract
import android.text.TextUtils import android.text.TextUtils
import androidx.core.app.Person
import androidx.core.graphics.drawable.IconCompat
import org.fossify.commons.helpers.SimpleContactsHelper
import org.fossify.commons.models.SimpleContact import org.fossify.commons.models.SimpleContact
import androidx.core.net.toUri
fun ArrayList<SimpleContact>.getThreadTitle(): String = TextUtils.join(", ", map { it.name }.toTypedArray()).orEmpty() fun ArrayList<SimpleContact>.getThreadTitle(): String {
return TextUtils.join(", ", map { it.name }.toTypedArray()).orEmpty()
}
fun ArrayList<SimpleContact>.getAddresses(): List<String> {
return flatMap { it.phoneNumbers }.map { it.normalizedNumber }
}
fun SimpleContact.toPerson(context: Context? = null): Person {
val uri =
Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_LOOKUP_URI, contactId.toString())
val iconCompat = if (context != null) {
loadIcon(context)
} else {
IconCompat.createWithContentUri(photoUri)
}
return Person.Builder()
.setName(name)
.setUri(uri.toString())
.setIcon(iconCompat)
.setKey(uri.toString())
.build()
}
fun SimpleContact.loadIcon(context: Context): IconCompat {
try {
val stream = context.contentResolver.openInputStream(photoUri.toUri())
val bitmap = BitmapFactory.decodeStream(stream)
stream?.close()
val iconCompat = IconCompat.createWithAdaptiveBitmap(bitmap)
return iconCompat
} catch (e: Exception) {
return IconCompat.createWithAdaptiveBitmap(
SimpleContactsHelper(context).getContactLetterIcon(name)
)
}
}
fun ArrayList<SimpleContact>.getAddresses() = flatMap { it.phoneNumbers }.map { it.normalizedNumber }

View file

@ -46,6 +46,7 @@ const val LAST_RECYCLE_BIN_CHECK = "last_recycle_bin_check"
const val IS_RECYCLE_BIN = "is_recycle_bin" const val IS_RECYCLE_BIN = "is_recycle_bin"
const val IS_ARCHIVE_AVAILABLE = "is_archive_available" const val IS_ARCHIVE_AVAILABLE = "is_archive_available"
const val CUSTOM_NOTIFICATIONS = "custom_notifications" const val CUSTOM_NOTIFICATIONS = "custom_notifications"
const val IS_LAUNCHED_FROM_SHORTCUT = "is_launched_from_shortcut"
private const val PATH = "org.fossify.org.fossify.messages.action." private const val PATH = "org.fossify.org.fossify.messages.action."
const val MARK_AS_READ = PATH + "mark_as_read" const val MARK_AS_READ = PATH + "mark_as_read"

View file

@ -17,9 +17,11 @@ import androidx.core.app.RemoteInput
import org.fossify.commons.extensions.getProperPrimaryColor import org.fossify.commons.extensions.getProperPrimaryColor
import org.fossify.commons.extensions.notificationManager import org.fossify.commons.extensions.notificationManager
import org.fossify.commons.helpers.SimpleContactsHelper import org.fossify.commons.helpers.SimpleContactsHelper
import org.fossify.commons.helpers.ensureBackgroundThread
import org.fossify.messages.R import org.fossify.messages.R
import org.fossify.messages.activities.ThreadActivity import org.fossify.messages.activities.ThreadActivity
import org.fossify.messages.extensions.config import org.fossify.messages.extensions.config
import org.fossify.messages.extensions.shortcutHelper
import org.fossify.messages.messaging.isShortCodeWithLetters import org.fossify.messages.messaging.isShortCodeWithLetters
import org.fossify.messages.receivers.DeleteSmsReceiver import org.fossify.messages.receivers.DeleteSmsReceiver
import org.fossify.messages.receivers.DirectReplyReceiver import org.fossify.messages.receivers.DirectReplyReceiver
@ -166,7 +168,22 @@ class NotificationHelper(private val context: Context) {
deleteSmsPendingIntent deleteSmsPendingIntent
).setChannelId(notificationChannelId) ).setChannelId(notificationChannelId)
} }
var shortcut = context.shortcutHelper.getShortcut(threadId)
if (shortcut == null) {
ensureBackgroundThread {
shortcut = context.shortcutHelper.createOrUpdateShortcut(threadId)
builder.setShortcutInfo(shortcut)
notificationManager.notify(notificationId, builder.build()) notificationManager.notify(notificationId, builder.build())
context.shortcutHelper.reportReceiveMessageUsage(threadId)
}
} else {
builder.setShortcutInfo(shortcut)
notificationManager.notify(notificationId, builder.build())
ensureBackgroundThread {
context.shortcutHelper.reportReceiveMessageUsage(threadId)
}
}
} }
@SuppressLint("NewApi") @SuppressLint("NewApi")

View file

@ -0,0 +1,170 @@
package org.fossify.messages.helpers
import android.content.Context
import android.content.Intent
import androidx.core.app.Person
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.core.graphics.drawable.toBitmap
import androidx.core.text.isDigitsOnly
import org.fossify.commons.extensions.getMyContactsCursor
import org.fossify.commons.helpers.MyContactsContentProvider
import org.fossify.commons.helpers.SimpleContactsHelper
import org.fossify.commons.helpers.isOnMainThread
import org.fossify.commons.models.SimpleContact
import org.fossify.messages.activities.ThreadActivity
import org.fossify.messages.extensions.conversationsDB
import org.fossify.messages.extensions.getThreadParticipants
import org.fossify.messages.extensions.toPerson
import org.fossify.messages.models.Conversation
class ShortcutHelper(private val context: Context) {
val contactsHelper = SimpleContactsHelper(context)
fun getShortcuts(): List<ShortcutInfoCompat> {
return ShortcutManagerCompat.getDynamicShortcuts(context)
}
fun getShortcut(threadId: Long): ShortcutInfoCompat? {
return getShortcuts().find { it.id == threadId.toString() }
}
fun buildShortcut(
conv: Conversation,
capabilities: List<String> = emptyList(),
): ShortcutInfoCompat {
val contactsMap: HashMap<Int, SimpleContact>? = if (!isOnMainThread()) {
val privateCursor =
context.getMyContactsCursor(favoritesOnly = false, withPhoneNumbersOnly = true)
val contacts = MyContactsContentProvider.getSimpleContacts(context, privateCursor)
HashMap(contacts.associateBy { it.rawId })
} else {
null
}
val participants = context.getThreadParticipants(conv.threadId, contactsMap)
val persons: Array<Person> = participants.map { it.toPerson(context) }.toTypedArray()
val intent = Intent(context, ThreadActivity::class.java).apply {
action = Intent.ACTION_VIEW
putExtra(THREAD_ID, conv.threadId)
putExtra(THREAD_TITLE, conv.title)
putExtra(IS_RECYCLE_BIN, false) // TODO: verify that thread isn't in recycle bin
putExtra(IS_LAUNCHED_FROM_SHORTCUT, true)
putExtra(THREAD_NUMBER, conv.phoneNumber.ifEmpty { "unknown_phone_number" })
addCategory(Intent.CATEGORY_DEFAULT)
addCategory(Intent.CATEGORY_BROWSABLE)
}
val shortcut = ShortcutInfoCompat.Builder(context, conv.threadId.toString()).apply {
setShortLabel(conv.title)
setLongLabel(conv.title)
setIsConversation()
setLongLived(true)
setPersons(persons)
setIntent(intent)
setRank(1)
if (!conv.isGroupConversation && !conv.usesCustomTitle) {
setIcon(persons[0].icon)
} else {
val icon = if (conv.isGroupConversation) {
IconCompat.createWithAdaptiveBitmap(
contactsHelper.getColoredGroupIcon(conv.title).toBitmap()
)
} else {
IconCompat.createWithAdaptiveBitmap(
contactsHelper.getContactLetterIcon(conv.title)
)
}
setIcon(icon)
}
capabilities.forEach { c ->
addCapabilityBinding(c)
}
if (!shouldPresentShortcut(conv)) {
setRank(99)
}
}.build()
return shortcut
}
fun buildShortcut(
threadId: Long,
capabilities: List<String> = emptyList(),
): ShortcutInfoCompat {
val conv = if (!isOnMainThread()) {
context.conversationsDB.getConversationWithThreadId(threadId)
} else {
null
}
if (conv == null) {
val conv = Conversation(
threadId = threadId,
snippet = "",
date = 0,
read = false,
title = threadId.toString(),
photoUri = "",
isGroupConversation = false,
phoneNumber = "",
isScheduled = false,
usesCustomTitle = false,
isArchived = false,
)
return buildShortcut(conv, capabilities)
}
return buildShortcut(conv, capabilities)
}
fun createOrUpdateShortcut(conv: Conversation): ShortcutInfoCompat {
val shortcut = buildShortcut(conv)
createOrUpdateShortcut(shortcut)
return shortcut
}
fun createOrUpdateShortcut(threadId: Long): ShortcutInfoCompat {
val shortcut = buildShortcut(threadId)
createOrUpdateShortcut(shortcut)
return shortcut
}
private fun createOrUpdateShortcut(shortcut: ShortcutInfoCompat) {
if (getShortcut(shortcut.id.toLong()) != null) {
ShortcutManagerCompat.updateShortcuts(context, listOf(shortcut))
return
}
ShortcutManagerCompat.pushDynamicShortcut(context, shortcut)
}
/**
* Report the usage of a thread. create shortcut if it doesn't exist
*/
fun reportSendMessageUsage(threadId: Long) {
val shortcut = buildShortcut(threadId, listOf("actions.intent.SEND_MESSAGE"))
ShortcutManagerCompat.pushDynamicShortcut(context, shortcut)
}
fun reportReceiveMessageUsage(threadId: Long) {
val shortcut = buildShortcut(threadId, listOf("actions.intent.RECEIVE_MESSAGE"))
ShortcutManagerCompat.pushDynamicShortcut(context, shortcut)
}
fun removeShortcutForThread(threadId: Long) {
val shortcut = getShortcut(threadId) ?: return
ShortcutManagerCompat.removeDynamicShortcuts(context, mutableListOf(shortcut.id))
}
fun shouldPresentShortcut(conv: Conversation): Boolean {
if (conv.isGroupConversation) {
return true
}
if (conv.isArchived || !conv.phoneNumber.isDigitsOnly()) {
return false
}
return true
}
}

View file

@ -7,9 +7,12 @@ import android.widget.Toast.LENGTH_LONG
import com.klinker.android.send_message.Settings import com.klinker.android.send_message.Settings
import org.fossify.commons.extensions.showErrorToast import org.fossify.commons.extensions.showErrorToast
import org.fossify.commons.extensions.toast import org.fossify.commons.extensions.toast
import org.fossify.commons.helpers.ensureBackgroundThread
import org.fossify.messages.R import org.fossify.messages.R
import org.fossify.messages.extensions.config import org.fossify.messages.extensions.config
import org.fossify.messages.extensions.getThreadId
import org.fossify.messages.extensions.messagingUtils import org.fossify.messages.extensions.messagingUtils
import org.fossify.messages.extensions.shortcutHelper
import org.fossify.messages.messaging.SmsException.Companion.EMPTY_DESTINATION_ADDRESS 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_PERSISTING_MESSAGE
import org.fossify.messages.messaging.SmsException.Companion.ERROR_SENDING_MESSAGE import org.fossify.messages.messaging.SmsException.Companion.ERROR_SENDING_MESSAGE
@ -94,6 +97,10 @@ fun Context.sendMessageCompat(
showErrorToast(e) showErrorToast(e)
} }
} }
ensureBackgroundThread {
val threadId = getThreadId(addresses.toSet())
shortcutHelper.reportSendMessageUsage(threadId)
}
} }
/** /**