commit 3b0b44fcd44fa67e7a7eacc5c61a003975230191
parent 9c5a590a6088aab4d9a623c732b47757c638a684
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date:   Mon, 27 Nov 2023 19:26:39 +0100

feat: bulk messaging actions

Diffstat:
Mcommon/src/main/assets/lang/en_US.json | 17++++++++++-------
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/action/EnumAction.kt | 2+-
Acore/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/BulkMessagingAction.kt | 167+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dcore/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/BulkRemoveFriends.kt | 120-------------------------------------------------------------------------------
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Messaging.kt | 21+++++++++++++++++++++
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/ActionManager.kt | 4++--
Acore/src/main/kotlin/me/rhunk/snapenhance/core/messaging/EnumBulkAction.kt | 9+++++++++
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/ConversationManager.kt | 20++++++++++++++++++++
8 files changed, 230 insertions(+), 130 deletions(-)

diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json @@ -116,7 +116,7 @@ "open_map": "Choose location on map", "check_for_updates": "Check for updates", "export_chat_messages": "Export Chat Messages", - "bulk_remove_friends": "Bulk Remove Friends" + "bulk_messaging_action": "Bulk Messaging Action" }, "features": { @@ -865,14 +865,17 @@ "incoming_follower": "Incoming Follower" }, - "bulk_remove_friends": { - "title": "Bulk Remove Friend", - "progress_status": "Removing friends {index} of {total}", - "selection_dialog_title": "Select friends to remove", - "selection_dialog_remove_button": "Remove Selection", + "bulk_messaging_action": { + "choose_action_title": "Choose an action", + "progress_status": "Processing {index} of {total}", + "selection_dialog_continue_button": "Continue", "confirmation_dialog": { "title": "Are you sure?", - "message": "This will remove all selected friends. This action cannot be undone." + "message": "This will affect all selected friends. This action cannot be undone." + }, + "actions": { + "remove_friends": "Remove Friends", + "clear_conversations": "Clear Conversations" } }, diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/action/EnumAction.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/action/EnumAction.kt @@ -9,7 +9,7 @@ enum class EnumAction( ) { CLEAN_CACHE("clean_snapchat_cache", exitOnFinish = true), EXPORT_CHAT_MESSAGES("export_chat_messages"), - BULK_REMOVE_FRIENDS("bulk_remove_friends"); + BULK_MESSAGING_ACTION("bulk_messaging_action"); companion object { const val ACTION_PARAMETER = "se_action" diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/BulkMessagingAction.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/BulkMessagingAction.kt @@ -0,0 +1,166 @@ +package me.rhunk.snapenhance.core.action.impl + +import android.widget.ProgressBar +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import me.rhunk.snapenhance.common.data.FriendLinkType +import me.rhunk.snapenhance.core.action.AbstractAction +import me.rhunk.snapenhance.core.features.impl.experiments.AddFriendSourceSpoof +import me.rhunk.snapenhance.core.features.impl.messaging.Messaging +import me.rhunk.snapenhance.core.messaging.EnumBulkAction +import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper + +class BulkMessagingAction : AbstractAction() { + private val translation by lazy { context.translation.getCategory("bulk_messaging_action") } + + private fun removeAction(ids: List<String>, action: (String) -> Unit = {}) { + var index = 0 + val dialog = ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) + .setTitle("...") + .setView(ProgressBar(context.mainActivity)) + .setCancelable(false) + .show() + + context.coroutineScope.launch { + ids.forEach { id -> + runCatching { + action(id) + }.onFailure { + context.log.error("Failed to process $it", it) + context.shortToast("Failed to process $id") + } + index++ + withContext(Dispatchers.Main) { + dialog.setTitle( + translation.format("progress_status", "index" to index.toString(), "total" to ids.size.toString()) + ) + } + delay(500) + } + withContext(Dispatchers.Main) { + dialog.dismiss() + } + } + } + + private suspend fun askActionType() = suspendCancellableCoroutine { cont -> + context.runOnUiThread { + ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) + .setTitle(translation["choose_action_title"]) + .setItems(EnumBulkAction.entries.map { translation["actions.${it.key}"] }.toTypedArray()) { _, which -> + cont.resumeWith(Result.success(EnumBulkAction.entries[which])) + } + .setOnCancelListener { + cont.resumeWith(Result.success(null)) + } + .show() + } + } + + private fun confirmationDialog(onConfirm: () -> Unit) { + ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) + .setTitle(translation["confirmation_dialog.title"]) + .setMessage(translation["confirmation_dialog.message"]) + .setPositiveButton(context.translation["button.positive"]) { _, _ -> + onConfirm() + } + .setNegativeButton(context.translation["button.negative"]) { _, _ -> } + .show() + } + + override fun run() { + val userIdBlacklist = arrayOf( + context.database.myUserId, + "b42f1f70-5a8b-4c53-8c25-34e7ec9e6781", // myai + "84ee8839-3911-492d-8b94-72dd80f3713a", // teamsnapchat + ) + + context.coroutineScope.launch(Dispatchers.Main) { + val bulkAction = askActionType() ?: return@launch + + val friends = context.database.getAllFriends().filter { + it.userId !in userIdBlacklist && + it.addedTimestamp != -1L && + it.friendLinkType == FriendLinkType.MUTUAL.value || + it.friendLinkType == FriendLinkType.OUTGOING.value + }.sortedByDescending { + it.friendLinkType == FriendLinkType.OUTGOING.value + } + + val selectedFriends = mutableListOf<String>() + + ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) + .setTitle(translation["actions.${bulkAction.key}"]) + .setMultiChoiceItems(friends.map { friend -> + (friend.displayName?.let { + "$it (${friend.mutableUsername})" + } ?: friend.mutableUsername) + + ": ${context.translation["friendship_link_type.${FriendLinkType.fromValue(friend.friendLinkType).shortName}"]}" + }.toTypedArray(), null) { _, which, isChecked -> + if (isChecked) { + selectedFriends.add(friends[which].userId!!) + } else { + selectedFriends.remove(friends[which].userId) + } + } + .setPositiveButton(translation["selection_dialog_continue_button"]) { _, _ -> + confirmationDialog { + when (bulkAction) { + EnumBulkAction.REMOVE_FRIENDS -> { + removeAction(selectedFriends) { + removeFriend(it) + } + } + EnumBulkAction.CLEAR_CONVERSATIONS -> clearConversations(selectedFriends) + } + } + } + .setNegativeButton(context.translation["button.cancel"]) { dialog, _ -> + dialog.dismiss() + } + .setCancelable(false) + .show() + } + } + + private fun clearConversations(friendIds: List<String>) { + val messaging = context.feature(Messaging::class) + + messaging.conversationManager?.apply { + getOneOnOneConversationIds(friendIds, onError = { error -> + context.shortToast("Failed to fetch conversations: $error") + }, onSuccess = { conversations -> + context.runOnUiThread { + removeAction(conversations.map { it.second }.distinct()) { + messaging.clearConversationFromFeed(it, onError = { error -> + context.shortToast("Failed to clear conversation: $error") + }) + } + } + }) + } + } + + private fun removeFriend(userId: String) { + val friendRelationshipChangerMapping = context.mappings.getMappedMap("FriendRelationshipChanger") + val friendRelationshipChangerInstance = context.feature(AddFriendSourceSpoof::class).friendRelationshipChangerInstance!! + + val removeFriendMethod = friendRelationshipChangerInstance::class.java.methods.first { + it.name == friendRelationshipChangerMapping["removeFriendMethod"].toString() + } + + val completable = removeFriendMethod.invoke(friendRelationshipChangerInstance, + userId, // userId + removeFriendMethod.parameterTypes[1].enumConstants.first { it.toString() == "DELETED_BY_MY_FRIENDS" }, // source + null, // unknown + null, // unknown + null // InteractionPlacementInfo + )!! + completable::class.java.methods.first { + it.name == "subscribe" && it.parameterTypes.isEmpty() + }.invoke(completable) + } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/BulkRemoveFriends.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/BulkRemoveFriends.kt @@ -1,119 +0,0 @@ -package me.rhunk.snapenhance.core.action.impl - -import android.widget.ProgressBar -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import me.rhunk.snapenhance.common.data.FriendLinkType -import me.rhunk.snapenhance.core.action.AbstractAction -import me.rhunk.snapenhance.core.features.impl.experiments.AddFriendSourceSpoof -import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper - -class BulkRemoveFriends : AbstractAction() { - private val translation by lazy { context.translation.getCategory("bulk_remove_friends") } - - private fun removeFriends(friendIds: List<String>) { - var index = 0 - val dialog = ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) - .setTitle("...") - .setView(ProgressBar(context.mainActivity)) - .setCancelable(false) - .show() - - context.coroutineScope.launch { - friendIds.forEach { userId -> - runCatching { - removeFriend(userId) - }.onFailure { - context.log.error("Failed to remove friend $it", it) - context.shortToast("Failed to remove friend $userId") - } - index++ - withContext(Dispatchers.Main) { - dialog.setTitle( - translation.format("progress_status", "index" to index.toString(), "total" to friendIds.size.toString()) - ) - } - delay(500) - } - withContext(Dispatchers.Main) { - dialog.dismiss() - } - } - } - - private fun confirmationDialog(onConfirm: () -> Unit) { - ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) - .setTitle(translation["confirmation_dialog.title"]) - .setMessage(translation["confirmation_dialog.message"]) - .setPositiveButton(context.translation["button.positive"]) { _, _ -> - onConfirm() - } - .setNegativeButton(context.translation["button.negative"]) { _, _ -> } - .show() - } - - override fun run() { - val userIdBlacklist = arrayOf( - context.database.myUserId, - "b42f1f70-5a8b-4c53-8c25-34e7ec9e6781", // myai - "84ee8839-3911-492d-8b94-72dd80f3713a", // teamsnapchat - ) - - context.coroutineScope.launch(Dispatchers.Main) { - val friends = context.database.getAllFriends().filter { - it.userId !in userIdBlacklist && - it.addedTimestamp != -1L && - it.friendLinkType == FriendLinkType.MUTUAL.value || - it.friendLinkType == FriendLinkType.OUTGOING.value - }.sortedByDescending { - it.friendLinkType == FriendLinkType.OUTGOING.value - } - - val selectedFriends = mutableListOf<String>() - - ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) - .setTitle(translation["selection_dialog_title"]) - .setMultiChoiceItems(friends.map { friend -> - (friend.displayName?.let { - "$it (${friend.mutableUsername})" - } ?: friend.mutableUsername) + - ": ${context.translation["friendship_link_type.${FriendLinkType.fromValue(friend.friendLinkType).shortName}"]}" - }.toTypedArray(), null) { _, which, isChecked -> - if (isChecked) { - selectedFriends.add(friends[which].userId!!) - } else { - selectedFriends.remove(friends[which].userId) - } - } - .setPositiveButton(translation["selection_dialog_remove_button"]) { _, _ -> - confirmationDialog { - removeFriends(selectedFriends) - } - } - .setNegativeButton(context.translation["button.cancel"]) { _, _ -> } - .show() - } - } - - private fun removeFriend(userId: String) { - val friendRelationshipChangerMapping = context.mappings.getMappedMap("FriendRelationshipChanger") - val friendRelationshipChangerInstance = context.feature(AddFriendSourceSpoof::class).friendRelationshipChangerInstance!! - - val removeFriendMethod = friendRelationshipChangerInstance::class.java.methods.first { - it.name == friendRelationshipChangerMapping["removeFriendMethod"].toString() - } - - val completable = removeFriendMethod.invoke(friendRelationshipChangerInstance, - userId, // userId - removeFriendMethod.parameterTypes[1].enumConstants.first { it.toString() == "DELETED_BY_MY_FRIENDS" }, // source - null, // unknown - null, // unknown - null // InteractionPlacementInfo - )!! - completable::class.java.methods.first { - it.name == "subscribe" && it.parameterTypes.isEmpty() - }.invoke(completable) - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Messaging.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Messaging.kt @@ -15,10 +15,12 @@ import me.rhunk.snapenhance.core.util.ktx.getObjectFieldOrNull import me.rhunk.snapenhance.core.wrapper.impl.ConversationManager import me.rhunk.snapenhance.core.wrapper.impl.Message import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID +import me.rhunk.snapenhance.core.wrapper.impl.toSnapUUID class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC or FeatureLoadParams.INIT_ASYNC or FeatureLoadParams.INIT_SYNC) { var conversationManager: ConversationManager? = null private set + private var conversationManagerDelegate: Any? = null var openedConversationUUID: SnapUUID? = null private set var lastFetchConversationUserUUID: SnapUUID? = null @@ -43,6 +45,22 @@ class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_C fun getFeedCachedMessageIds(conversationId: String) = feedCachedSnapMessages[conversationId] + fun clearConversationFromFeed(conversationId: String, onError : (String) -> Unit = {}, onSuccess : () -> Unit = {}) { + conversationManager?.clearConversation(conversationId, onError = { onError(it) }, onSuccess = { + runCatching { + conversationManagerDelegate!!.let { + it::class.java.methods.first { method -> + method.name == "onConversationRemoved" + }.invoke(conversationManagerDelegate, conversationId.toSnapUUID().instanceNonNull()) + } + onSuccess() + }.onFailure { + context.log.error("Failed to invoke onConversationRemoved: $it") + onError(it.message ?: "Unknown error") + } + }) + } + override fun onActivityCreate() { context.mappings.getMappedObjectNullable("FriendsFeedEventDispatcher").let { it as? Map<*, *> }?.let { mappings -> findClass(mappings["class"].toString()).hook("onItemLongPress", HookStage.BEFORE) { param -> @@ -57,6 +75,9 @@ class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_C } } + context.mappings.getMappedClass("callbacks", "ConversationManagerDelegate").hookConstructor(HookStage.AFTER) { param -> + conversationManagerDelegate = param.thisObject() + } context.classCache.feedEntry.hookConstructor(HookStage.AFTER) { param -> val instance = param.thisObject<Any>() diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/ActionManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/ActionManager.kt @@ -3,7 +3,7 @@ package me.rhunk.snapenhance.core.manager.impl import android.content.Intent import me.rhunk.snapenhance.common.action.EnumAction import me.rhunk.snapenhance.core.ModContext -import me.rhunk.snapenhance.core.action.impl.BulkRemoveFriends +import me.rhunk.snapenhance.core.action.impl.BulkMessagingAction import me.rhunk.snapenhance.core.action.impl.CleanCache import me.rhunk.snapenhance.core.action.impl.ExportChatMessages import me.rhunk.snapenhance.core.manager.Manager @@ -16,7 +16,7 @@ class ActionManager( mapOf( EnumAction.CLEAN_CACHE to CleanCache::class, EnumAction.EXPORT_CHAT_MESSAGES to ExportChatMessages::class, - EnumAction.BULK_REMOVE_FRIENDS to BulkRemoveFriends::class, + EnumAction.BULK_MESSAGING_ACTION to BulkMessagingAction::class, ).map { it.key to it.value.java.getConstructor().newInstance().apply { this.context = modContext diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/EnumBulkAction.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/EnumBulkAction.kt @@ -0,0 +1,8 @@ +package me.rhunk.snapenhance.core.messaging + +enum class EnumBulkAction( + val key: String, +) { + REMOVE_FRIENDS("remove_friends"), + CLEAR_CONVERSATIONS("clear_conversations"), +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/ConversationManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/ConversationManager.kt @@ -22,6 +22,8 @@ class ConversationManager( private val fetchMessagesByServerIds by lazy { findMethodByName("fetchMessagesByServerIds") } private val displayedMessagesMethod by lazy { findMethodByName("displayedMessages") } private val fetchMessage by lazy { findMethodByName("fetchMessage") } + private val clearConversation by lazy { findMethodByName("clearConversation") } + private val getOneOnOneConversationIds by lazy { findMethodByName("getOneOnOneConversationIds") } fun updateMessage(conversationId: String, messageId: Long, action: MessageUpdate, onResult: CallbackResult = {}) { @@ -128,4 +130,22 @@ class ConversationManager( }.build() ) } + + fun clearConversation(conversationId: String, onSuccess: () -> Unit, onError: (error: String) -> Unit) { + val callback = CallbackBuilder(context.mappings.getMappedClass("callbacks", "Callback")) + .override("onSuccess") { onSuccess() } + .override("onError") { onError(it.arg<Any>(0).toString()) }.build() + clearConversation.invoke(instanceNonNull(), conversationId.toSnapUUID().instanceNonNull(), callback) + } + + fun getOneOnOneConversationIds(userIds: List<String>, onSuccess: (List<Pair<String, String>>) -> Unit, onError: (error: String) -> Unit) { + val callback = CallbackBuilder(context.mappings.getMappedClass("callbacks", "GetOneOnOneConversationIdsCallback")) + .override("onSuccess") { param -> + onSuccess(param.arg<ArrayList<*>>(0).map { + SnapUUID(it.getObjectField("mUserId")).toString() to SnapUUID(it.getObjectField("mConversationId")).toString() + }) + } + .override("onError") { onError(it.arg<Any>(0).toString()) }.build() + getOneOnOneConversationIds.invoke(instanceNonNull(), userIds.map { it.toSnapUUID().instanceNonNull() }.toMutableList(), callback) + } } \ No newline at end of file