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:
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