Skip to content
Open
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ buildscript {
ext.junit_version = '4.12'
ext.kotlin_version = '1.7.21'
ext.lifecycle_version = '2.1.0'
ext.material_version = '1.0.0'
ext.material_version = '1.6.1'
ext.mockito_version = '2.18.3'
ext.moshi_version = '1.8.0'
ext.okhttp3_version = '4.10.0'
Expand Down
23 changes: 22 additions & 1 deletion data/src/main/assets/emojis/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,26 @@
"emoji_reaction_ios_exclamation_removed": "^(?s)Removed an exclamation from “(.+?)”$",

"emoji_reaction_ios_question_mark_added": "^(?s)Questioned “(.+?)”$",
"emoji_reaction_ios_question_mark_removed": "^(?s)Removed a question mark from “(.+?)”$"
"emoji_reaction_ios_question_mark_removed": "^(?s)Removed a question mark from “(.+?)”$",

"emoji_reaction_ios_generic_added_template": "Reacted %1$s to “%2$s”",
"emoji_reaction_ios_generic_removed_template": "Removed %1$s from “%2$s”",

"emoji_reaction_ios_heart_added_template": "Loved “%s”",
"emoji_reaction_ios_heart_removed_template": "Removed a heart from “%s”",

"emoji_reaction_ios_like_added_template": "Liked “%s”",
"emoji_reaction_ios_like_removed_template": "Removed a like from “%s”",

"emoji_reaction_ios_dislike_added_template": "Disliked “%s”",
"emoji_reaction_ios_dislike_removed_template": "Removed a dislike from “%s”",

"emoji_reaction_ios_laugh_added_template": "Laughed at “%s”",
"emoji_reaction_ios_laugh_removed_template": "Removed a laugh from “%s”",

"emoji_reaction_ios_exclamation_added_template": "Emphasized “%s”",
"emoji_reaction_ios_exclamation_removed_template": "Removed an exclamation from “%s”",

"emoji_reaction_ios_question_mark_added_template": "Questioned “%s”",
"emoji_reaction_ios_question_mark_removed_template": "Removed a question mark from “%s”"
}
16 changes: 15 additions & 1 deletion data/src/main/java/com/moez/QKSMS/migration/QkRealmMigration.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ class QkRealmMigration @Inject constructor(
) : RealmMigration {

companion object {
const val SCHEMA_VERSION: Long = 15
const val SCHEMA_VERSION: Long = 16
}

@SuppressLint("ApplySharedPref")
Expand Down Expand Up @@ -300,6 +300,20 @@ class QkRealmMigration @Inject constructor(
version ++
}

if (version == 15L) {
realm.schema.get("EmojiReaction")
?.takeIf { it.hasField("fromMe").not() }
?.addField("fromMe", Boolean::class.java, FieldAttribute.REQUIRED)
?.transform { reaction -> reaction.setBoolean("fromMe", false) }

realm.schema.get("EmojiReaction")
?.takeIf { it.hasField("format").not() }
?.addField("format", String::class.java, FieldAttribute.REQUIRED)
?.transform { reaction -> reaction.setString("format", "") }

version ++
}

check(version >= SCHEMA_VERSION) {
"Migration from v$oldVersion to v$newVersion failed at v$version"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,23 +24,35 @@ import dev.octoshrimpy.quik.manager.KeyManager
import dev.octoshrimpy.quik.model.EmojiReaction
import dev.octoshrimpy.quik.model.Message
import dev.octoshrimpy.quik.util.EmojiPatternStrings
import dev.octoshrimpy.quik.util.Preferences
import io.realm.Realm
import io.realm.Sort
import timber.log.Timber
import java.util.Locale
import javax.inject.Inject

class EmojiReactionRepositoryImpl @Inject constructor(
private val context: Context,
private val keyManager: KeyManager,
private val moshi: Moshi,
private val prefs: Preferences,
) : EmojiReactionRepository {
companion object {
// Invisible delimiters used by the Google Messages reaction wire format
private const val HAIR = " " // hair space, wraps the message parts
private const val ZWSP = "​" // zero-width space, wraps the emoji when reacting
private const val ZWNJ = "‌" // zero-width non-joiner, wraps the emoji when un-reacting
}

// Locale-tagged pattern/template strings loaded from assets, kept for outgoing generation
private val patternStringsByLocale = mutableMapOf<String, EmojiPatternStrings>()
// We use an ordered map to make sure we can test tapback regexes before generic ones
private val reactionPatterns: LinkedHashMap<Regex, (MatchResult) -> ParsedEmojiReaction?> = linkedMapOf(
Regex( // Google Messages
"(?s)^\u200a[^\u200b\u200a]*\u200b([^\u200b]*)\u200b[^\u200b\u200a]*\u200a(.*)\u200a[^\u200b\u200a]*\u200a\\Z"
) to { match ->
ParsedEmojiReaction(
match.groupValues[1], match.groupValues[2]
match.groupValues[1], match.groupValues[2], format = EmojiReaction.FORMAT_GOOGLE
)
}
)
Expand All @@ -49,14 +61,16 @@ class EmojiReactionRepositoryImpl @Inject constructor(
"(?s)^\u200a[^\u200c\u200a]*\u200c([^\u200c]*)\u200c[^\u200c\u200a]*\u200a(.*)\u200a[^\u200c\u200a]*\u200a\\Z"
) to { match ->
ParsedEmojiReaction(
match.groupValues[1], match.groupValues[2], isRemoval = true
match.groupValues[1], match.groupValues[2], isRemoval = true,
format = EmojiReaction.FORMAT_GOOGLE
)
}
)

init {
val assetEntries = loadEmojiPatternEntriesFromAssets()
assetEntries.forEach { (localeTag, strings) ->
patternStringsByLocale[localeTag] = strings
try {
addPatternsForLocaleStrings(localeTag, strings, reactionPatterns, removalPatterns)
} catch (e: Exception) {
Expand All @@ -83,24 +97,24 @@ class EmojiReactionRepositoryImpl @Inject constructor(
).forEach { (emoji, added, removed) ->
added?.let {
reactionPatterns[Regex(it)] =
{ match -> ParsedEmojiReaction(emoji, match.groupValues[1]) }
{ match -> ParsedEmojiReaction(emoji, match.groupValues[1], format = EmojiReaction.FORMAT_IOS_TAPBACK) }
}
removed?.let {
removalPatterns[Regex(it)] =
{ match -> ParsedEmojiReaction(emoji, match.groupValues[1], isRemoval = true) }
{ match -> ParsedEmojiReaction(emoji, match.groupValues[1], isRemoval = true, format = EmojiReaction.FORMAT_IOS_TAPBACK) }
}
}

// Generic iOS emoji patterns
strings.iosGenericAdded?.let { pattern ->
reactionPatterns[Regex(pattern)] = { match ->
if (match.groupValues.getOrNull(1) == "with a sticker") null // TODO: localize "with a sticker"
else ParsedEmojiReaction(match.groupValues[1], match.groupValues[2])
else ParsedEmojiReaction(match.groupValues[1], match.groupValues[2], format = EmojiReaction.FORMAT_IOS_GENERIC)
}
}
strings.iosGenericRemoved?.let { pattern ->
removalPatterns[Regex(pattern)] = { match ->
ParsedEmojiReaction(match.groupValues[1], match.groupValues[2], isRemoval = true)
ParsedEmojiReaction(match.groupValues[1], match.groupValues[2], isRemoval = true, format = EmojiReaction.FORMAT_IOS_GENERIC)
}
}

Expand Down Expand Up @@ -159,6 +173,85 @@ class EmojiReactionRepositoryImpl @Inject constructor(
return null
}

override fun buildReactionBody(
emoji: String,
targetText: String,
isRemoval: Boolean,
format: String,
): String = when {
format.startsWith("ios") -> buildIosReactionBody(emoji, targetText, isRemoval)
else -> buildGoogleReactionBody(emoji, targetText, isRemoval)
}

/**
* Reconstructs the invisible-delimiter encoding that Google Messages (and Quik's own parser)
* understand. The visible words are cosmetic; the hair-space / zero-width delimiters carry the
* structure, so this is locale-independent.
*/
private fun buildGoogleReactionBody(emoji: String, targetText: String, isRemoval: Boolean): String {
val emojiDelim = if (isRemoval) ZWNJ else ZWSP
val verb = if (isRemoval) "Removed " else "Reacted "
val preposition = if (isRemoval) " from " else " to "
return HAIR + verb + emojiDelim + emoji + emojiDelim + preposition +
HAIR + targetText + HAIR + HAIR
}

private fun buildIosReactionBody(emoji: String, targetText: String, isRemoval: Boolean): String {
val strings = templatesForCurrentLocale()
iosTapbackTemplate(emoji, isRemoval, strings)?.let { template ->
return String.format(template, targetText)
}
val generic = when {
isRemoval -> strings?.iosGenericRemovedTemplate ?: "Removed %1\$s from “%2\$s”"
else -> strings?.iosGenericAddedTemplate ?: "Reacted %1\$s to “%2\$s”"
}
return String.format(generic, emoji, targetText)
}

/** Returns the iOS-readable template for one of the six standard tapback emojis, else null. */
private fun iosTapbackTemplate(
emoji: String,
isRemoval: Boolean,
strings: EmojiPatternStrings?,
): String? = when (emoji) {
"❤️" -> if (isRemoval) strings?.iosHeartRemovedTemplate ?: "Removed a heart from “%s”"
else strings?.iosHeartAddedTemplate ?: "Loved “%s”"
"👍" -> if (isRemoval) strings?.iosLikeRemovedTemplate ?: "Removed a like from “%s”"
else strings?.iosLikeAddedTemplate ?: "Liked “%s”"
"👎" -> if (isRemoval) strings?.iosDislikeRemovedTemplate ?: "Removed a dislike from “%s”"
else strings?.iosDislikeAddedTemplate ?: "Disliked “%s”"
"😂" -> if (isRemoval) strings?.iosLaughRemovedTemplate ?: "Removed a laugh from “%s”"
else strings?.iosLaughAddedTemplate ?: "Laughed at “%s”"
"‼️" -> if (isRemoval) strings?.iosExclamationRemovedTemplate ?: "Removed an exclamation from “%s”"
else strings?.iosExclamationAddedTemplate ?: "Emphasized “%s”"
"❓" -> if (isRemoval) strings?.iosQuestionMarkRemovedTemplate ?: "Removed a question mark from “%s”"
else strings?.iosQuestionMarkAddedTemplate ?: "Questioned “%s”"
else -> null
}

private fun templatesForCurrentLocale(): EmojiPatternStrings? {
val locale = Locale.getDefault()
return patternStringsByLocale[locale.toLanguageTag().replace('-', '_')]
?: patternStringsByLocale[locale.language]
?: patternStringsByLocale["en"]
}

override fun resolveFormat(threadId: Long, realm: Realm): String =
when (prefs.reactionSendFormat.get()) {
Preferences.REACTION_FORMAT_GOOGLE -> EmojiReaction.FORMAT_GOOGLE
Preferences.REACTION_FORMAT_IOS -> EmojiReaction.FORMAT_IOS_TAPBACK
else -> {
// Auto: mirror the most recent reaction format we received in this thread
val lastReceived = realm.where(EmojiReaction::class.java)
.equalTo("threadId", threadId)
.equalTo("fromMe", false)
.sort("id", Sort.DESCENDING)
.findFirst()
if (lastReceived?.format?.startsWith("ios") == true) EmojiReaction.FORMAT_IOS_TAPBACK
else EmojiReaction.FORMAT_GOOGLE
}
}

private fun parseTruncatedMessages(originalMessageText: String): Regex {
val reactionText = originalMessageText.trim()

Expand Down Expand Up @@ -203,6 +296,13 @@ class EmojiReactionRepositoryImpl @Inject constructor(
return null
}

/**
* Two reactions are from the same reactor if they're both ours, or both incoming from the same
* address. Used to enforce one reaction per person per message.
*/
private fun sameReactor(a: EmojiReaction, b: EmojiReaction): Boolean =
if (b.fromMe) a.fromMe else (!a.fromMe && a.senderAddress == b.senderAddress)

private fun removeEmojiReaction(
reactionMessage: Message,
reaction: ParsedEmojiReaction,
Expand All @@ -214,8 +314,11 @@ class EmojiReactionRepositoryImpl @Inject constructor(
return
}

val fromMe = reactionMessage.isMe()
val existingReaction = targetMessage.emojiReactions.find { candidate ->
candidate.senderAddress == reactionMessage.address && candidate.emoji == reaction.emoji
candidate.emoji == reaction.emoji &&
if (fromMe) candidate.fromMe
else (!candidate.fromMe && candidate.senderAddress == reactionMessage.address)
}

if (existingReaction != null) {
Expand Down Expand Up @@ -247,15 +350,17 @@ class EmojiReactionRepositoryImpl @Inject constructor(
emoji = parsedReaction.emoji
originalMessageText = parsedReaction.originalMessage
threadId = reactionMessage.threadId
fromMe = reactionMessage.isMe()
format = parsedReaction.format
}
realm.insertOrUpdate(reaction)

if (targetMessage != null) {
reactionMessage.isEmojiReaction = true
realm.insertOrUpdate(reactionMessage)

// Overwrite any previous reaction from this sender for this target
val priorFromSender = targetMessage.emojiReactions.filter { it.senderAddress == reaction.senderAddress }
// Overwrite any previous reaction from this same reactor for this target
val priorFromSender = targetMessage.emojiReactions.filter { sameReactor(it, reaction) }
priorFromSender.forEach { it.deleteFromRealm() }

targetMessage.emojiReactions.add(reaction)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,7 @@ open class MessageRepositoryImpl @Inject constructor(
}
?: 0

private fun syncProviderMessage(uri: Uri, sendAsGroup: Boolean): Message? {
private fun syncProviderMessage(uri: Uri, sendAsGroup: Boolean, asReaction: Boolean = false): Message? {
// if uri doesn't have valid type
val type = when {
uri.toString().contains(TYPE_MMS) -> TYPE_MMS
Expand All @@ -428,6 +428,9 @@ open class MessageRepositoryImpl @Inject constructor(

cursorToMessage.map(Pair(cursor, CursorToMessage.MessageColumns(cursor))).apply {
this.sendAsGroup = sendAsGroup
// mark reaction messages hidden before they're first committed, so the raw
// reaction bubble never flashes in the conversation
this.isEmojiReaction = asReaction

if (isMms()) {
parts = RealmList<MmsPart>().apply {
Expand All @@ -446,7 +449,7 @@ open class MessageRepositoryImpl @Inject constructor(

override fun sendNewMessages(
subId: Int, toAddresses: Collection<String>, body: String,
attachments: Collection<Attachment>, sendAsGroup: Boolean, delayMs: Int
attachments: Collection<Attachment>, sendAsGroup: Boolean, delayMs: Int, asReaction: Boolean
): Collection<Message> {
Timber.v("sending message(s)")

Expand Down Expand Up @@ -606,7 +609,7 @@ open class MessageRepositoryImpl @Inject constructor(
return listOf()
}

val message = syncProviderMessage(messageUri, group)
val message = syncProviderMessage(messageUri, group, asReaction)
if (message == null) {
Timber.v("sync message failed for uri $messageUri")
return listOf()
Expand Down Expand Up @@ -691,6 +694,44 @@ open class MessageRepositoryImpl @Inject constructor(
?.let { message -> sendMessage(message) }
?: listOf()

override fun sendReaction(
subId: Int, targetMessageId: Long, emoji: String, isRemoval: Boolean
): Collection<Message> {
// Read the target's text/address and resolve the wire format up front
val target = getUnmanagedMessage(targetMessageId) ?: return listOf()
val targetText = target.getText(false)
Comment on lines +701 to +702

val format = Realm.getDefaultInstance().use { realm ->
reactions.resolveFormat(target.threadId, realm)
}
val body = reactions.buildReactionBody(emoji, targetText, isRemoval, format)

// Send it through the normal pipeline (delivery, retry, etc. all handled). asReaction hides
// the message from the moment it's created so the raw reaction text never flashes on screen.
val sent = sendNewMessages(subId, listOf(target.address), body, listOf(), false, 0, asReaction = true)

Comment on lines +709 to +712
// Hide the reaction message from the thread and record the reaction locally so it shows
// on the target bubble without waiting for a re-parse
Realm.getDefaultInstance().use { realm ->
realm.executeTransaction { r ->
val targetManaged = r.where(Message::class.java)
.equalTo("id", targetMessageId).findFirst()
sent.forEach { msg ->
val reactionManaged = r.where(Message::class.java)
.equalTo("id", msg.id).findFirst() ?: return@forEach
reactions.saveEmojiReaction(
reactionManaged,
ParsedEmojiReaction(emoji, targetText, isRemoval, format),
targetManaged,
r,
)
}
}
}

return sent
}

override fun cancelDelayedSmsAlarm(messageId: Long) =
(context.getSystemService(Context.ALARM_SERVICE) as AlarmManager)
.cancel(getIntentForDelayedSms(messageId))
Expand Down
18 changes: 18 additions & 0 deletions data/src/main/java/com/moez/QKSMS/util/EmojiPatternStrings.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,22 @@ data class EmojiPatternStrings(

@Json(name = "emoji_reaction_ios_question_mark_added") val iosQuestionMarkAdded: String? = null,
@Json(name = "emoji_reaction_ios_question_mark_removed") val iosQuestionMarkRemoved: String? = null,

// Forward templates used to *generate* outgoing iOS-readable reactions. "%1$s" is the emoji
// (generic template only) and the last "%s" is the target message text. When absent for a
// locale, English defaults are used.
@Json(name = "emoji_reaction_ios_generic_added_template") val iosGenericAddedTemplate: String? = null,
@Json(name = "emoji_reaction_ios_generic_removed_template") val iosGenericRemovedTemplate: String? = null,
@Json(name = "emoji_reaction_ios_heart_added_template") val iosHeartAddedTemplate: String? = null,
@Json(name = "emoji_reaction_ios_heart_removed_template") val iosHeartRemovedTemplate: String? = null,
@Json(name = "emoji_reaction_ios_like_added_template") val iosLikeAddedTemplate: String? = null,
@Json(name = "emoji_reaction_ios_like_removed_template") val iosLikeRemovedTemplate: String? = null,
@Json(name = "emoji_reaction_ios_dislike_added_template") val iosDislikeAddedTemplate: String? = null,
@Json(name = "emoji_reaction_ios_dislike_removed_template") val iosDislikeRemovedTemplate: String? = null,
@Json(name = "emoji_reaction_ios_laugh_added_template") val iosLaughAddedTemplate: String? = null,
@Json(name = "emoji_reaction_ios_laugh_removed_template") val iosLaughRemovedTemplate: String? = null,
@Json(name = "emoji_reaction_ios_exclamation_added_template") val iosExclamationAddedTemplate: String? = null,
@Json(name = "emoji_reaction_ios_exclamation_removed_template") val iosExclamationRemovedTemplate: String? = null,
@Json(name = "emoji_reaction_ios_question_mark_added_template") val iosQuestionMarkAddedTemplate: String? = null,
@Json(name = "emoji_reaction_ios_question_mark_removed_template") val iosQuestionMarkRemovedTemplate: String? = null,
)
Loading
Loading