commit 9f098834cfa61d80bc3c5fcac98e416c6f4b2be3
parent 50a43ee6ad81cd41cb6f70f96c9e990932856b32
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date:   Sun, 15 Oct 2023 13:32:39 +0200

refactor(manager/social): messaging task

Diffstat:
Aapp/src/main/kotlin/me/rhunk/snapenhance/messaging/MessagingTask.kt | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/MessagingPreview.kt | 247++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/SocialSection.kt | 9++-------
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Messaging.kt | 12++++++++++--
4 files changed, 269 insertions(+), 125 deletions(-)

diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/messaging/MessagingTask.kt b/app/src/main/kotlin/me/rhunk/snapenhance/messaging/MessagingTask.kt @@ -0,0 +1,125 @@ +package me.rhunk.snapenhance.messaging + +import androidx.compose.runtime.MutableIntState +import kotlinx.coroutines.delay +import me.rhunk.snapenhance.bridge.snapclient.MessagingBridge +import me.rhunk.snapenhance.bridge.snapclient.types.Message +import me.rhunk.snapenhance.common.data.ContentType +import kotlin.random.Random + + +enum class MessagingTaskType( + val key: String +) { + SAVE("SAVE"), + UNSAVE("UNSAVE"), + DELETE("ERASE"), + READ("READ"), +} + +typealias MessagingTaskConstraint = Message.() -> Boolean + +object MessagingConstraints { + val USER_ID: (String) -> MessagingTaskConstraint = { userId: String -> + { + this.senderId == userId + } + } + val NO_USER_ID: (String) -> MessagingTaskConstraint = { userId: String -> + { + this.senderId != userId + } + } + val MY_USER_ID: (messagingBridge: MessagingBridge) -> MessagingTaskConstraint = { + val myUserId = it.myUserId + { + this.senderId == myUserId + } + } + val CONTENT_TYPE: (ContentType) -> MessagingTaskConstraint = { contentType: ContentType -> + { + this.contentType == contentType.id + } + } +} + +class MessagingTask( + private val messagingBridge: MessagingBridge, + private val conversationId: String, + private val taskType: MessagingTaskType, + private val constraints: List<MessagingTaskConstraint>, + private val processedMessageCount: MutableIntState, + private val onSuccess: (message: Message) -> Unit = {}, + private val onFailure: (message: Message, reason: String) -> Unit = { _, _ -> }, + private val overrideClientMessageIds: List<Long>? = null, + private val amountToProcess: Int? = null, +) { + private suspend fun processMessages( + messages: List<Message> + ) { + messages.forEach { message -> + if (constraints.any { !it(message) }) { + return@forEach + } + + val error = messagingBridge.updateMessage(conversationId, message.clientMessageId, taskType.key) + error?.takeIf { error != "DUPLICATE_REQUEST" }?.let { + onFailure(message, error) + } + onSuccess(message) + processedMessageCount.intValue++ + delay(Random.nextLong(50, 170)) + } + } + + fun hasFixedGoal() = overrideClientMessageIds?.takeIf { it.isNotEmpty() } != null || amountToProcess?.takeIf { it > 0 } != null + + suspend fun run() { + var processedOverrideMessages = 0 + var lastMessageId = Long.MAX_VALUE + + do { + val fetchedMessages = messagingBridge.fetchConversationWithMessagesPaginated( + conversationId, + 100, + lastMessageId + ) ?: return + + if (fetchedMessages.isEmpty()) { + break + } + + lastMessageId = fetchedMessages.first().clientMessageId + + overrideClientMessageIds?.let { ids -> + fetchedMessages.retainAll { message -> + ids.contains(message.clientMessageId) + } + } + + amountToProcess?.let { amount -> + while (processedMessageCount.intValue + fetchedMessages.size > amount) { + fetchedMessages.removeLastOrNull() + } + } + + processMessages(fetchedMessages.reversed()) + + overrideClientMessageIds?.let { ids -> + processedOverrideMessages += fetchedMessages.count { message -> + ids.contains(message.clientMessageId) + } + + if (processedOverrideMessages >= ids.size) { + return + } + } + + amountToProcess?.let { amount -> + if (processedMessageCount.intValue >= amount) { + return + } + } + } while (true) + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/MessagingPreview.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/MessagingPreview.kt @@ -1,18 +1,25 @@ package me.rhunk.snapenhance.ui.manager.sections.social +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.rounded.BookmarkAdded +import androidx.compose.material.icons.rounded.BookmarkBorder +import androidx.compose.material.icons.rounded.DeleteForever +import androidx.compose.material.icons.rounded.RemoveRedEye import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp import kotlinx.coroutines.* @@ -23,71 +30,121 @@ import me.rhunk.snapenhance.common.data.ContentType import me.rhunk.snapenhance.common.data.SocialScope import me.rhunk.snapenhance.common.util.protobuf.ProtoReader import me.rhunk.snapenhance.common.util.snap.SnapWidgetBroadcastReceiverHelper -import me.rhunk.snapenhance.ui.util.AlertDialogs +import me.rhunk.snapenhance.messaging.MessagingConstraints +import me.rhunk.snapenhance.messaging.MessagingTask +import me.rhunk.snapenhance.messaging.MessagingTaskConstraint +import me.rhunk.snapenhance.messaging.MessagingTaskType import me.rhunk.snapenhance.ui.util.Dialog -import kotlin.random.Random class MessagingPreview( private val context: RemoteSideContext, private val scope: SocialScope, private val scopeId: String ) { - private val alertDialogs by lazy { AlertDialogs(context.translation) } - private lateinit var coroutineScope: CoroutineScope private lateinit var messagingBridge: MessagingBridge private lateinit var previewScrollState: LazyListState private val myUserId by lazy { messagingBridge.myUserId } private var conversationId: String? = null - private val messages = sortedMapOf<Long, Message>() + private val messages = sortedMapOf<Long, Message>() // server message id => message private var messageSize by mutableIntStateOf(0) private var lastMessageId = Long.MAX_VALUE - private val selectedMessages = mutableStateListOf<Long>() + private val selectedMessages = mutableStateListOf<Long>() // client message id private fun toggleSelectedMessage(messageId: Long) { if (selectedMessages.contains(messageId)) selectedMessages.remove(messageId) else selectedMessages.add(messageId) } + @Composable + private fun ActionButton( + text: String, + icon: ImageVector, + onClick: () -> Unit, + ) { + DropdownMenuItem( + onClick = onClick, + text = { + Row( + modifier = Modifier.padding(5.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null + ) + Text(text = text) + } + } + ) + } @Composable fun TopBarAction() { - var deletedMessageCount by remember { mutableIntStateOf(0) } - var messageDeleteJob by remember { mutableStateOf(null as Job?) } - - fun deleteIndividualMessage(serverMessageId: Long) { - val message = messages[serverMessageId] ?: return - if (message.senderId != myUserId) return - - val error = messagingBridge.updateMessage(conversationId, message.clientMessageId, "ERASE") - - if (error != null) { - context.shortToast("Failed to delete message: $error") - } else { - coroutineScope.launch { - deletedMessageCount++ - messages.remove(serverMessageId) - messageSize = messages.size + var showDropDown by remember { mutableStateOf(false) } + var activeTask by remember { mutableStateOf(null as MessagingTask?) } + var activeJob by remember { mutableStateOf(null as Job?) } + val processMessageCount = remember { mutableIntStateOf(0) } + + fun triggerMessagingTask(taskType: MessagingTaskType, constraints: List<MessagingTaskConstraint> = listOf(), onSuccess: (Message) -> Unit = {}) { + showDropDown = false + processMessageCount.intValue = 0 + activeTask = MessagingTask( + messagingBridge = messagingBridge, + conversationId = conversationId!!, + taskType = taskType, + constraints = constraints, + overrideClientMessageIds = selectedMessages.takeIf { it.isNotEmpty() }?.toList(), + processedMessageCount = processMessageCount, + onFailure = { message, reason -> + context.log.verbose("Failed to process message ${message.clientMessageId}: $reason") + } + ) + selectedMessages.clear() + activeJob = coroutineScope.launch(Dispatchers.IO) { + activeTask?.run() + withContext(Dispatchers.Main) { + activeTask = null + activeJob = null + } + }.also { job -> + job.invokeOnCompletion { + if (it != null) { + context.log.verbose("Failed to process messages: ${it.message}") + return@invokeOnCompletion + } + context.longToast("Processed ${processMessageCount.intValue} messages") } } } - if (messageDeleteJob != null) { + if (activeJob != null) { Dialog(onDismissRequest = { - messageDeleteJob?.cancel() - messageDeleteJob = null + activeJob?.cancel() + activeJob = null + activeTask = null }) { - Card { - Column( - modifier = Modifier - .padding(20.dp) - .fillMaxWidth(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text("Deleting messages ($deletedMessageCount)") - Spacer(modifier = Modifier.height(10.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .border(1.dp, MaterialTheme.colorScheme.onSurface, RoundedCornerShape(20.dp)) + .padding(15.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(5.dp) + ) { + Text("Processed ${processMessageCount.intValue} messages") + if (activeTask?.hasFixedGoal() == true) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .padding(5.dp), + progress = processMessageCount.intValue.toFloat() / selectedMessages.size.toFloat(), + color = MaterialTheme.colorScheme.primary + ) + } else { CircularProgressIndicator( modifier = Modifier .padding() @@ -100,94 +157,52 @@ class MessagingPreview( } } + IconButton(onClick = { showDropDown = !showDropDown }) { + Icon(imageVector = Icons.Filled.MoreVert, contentDescription = null) + } if (selectedMessages.isNotEmpty()) { IconButton(onClick = { - deletedMessageCount = 0 - messageDeleteJob = coroutineScope.launch(Dispatchers.IO) { - selectedMessages.toList().also { - selectedMessages.clear() - }.forEach { messageId -> - deleteIndividualMessage(messageId) - } - }.apply { - invokeOnCompletion { - context.shortToast("Successfully deleted $deletedMessageCount messages") - messageDeleteJob = null - } - } - }) { - Icon( - imageVector = Icons.Filled.Delete, - contentDescription = "Delete" - ) - } - - IconButton(onClick = { selectedMessages.clear() }) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = "Close" - ) + Icon(imageVector = Icons.Filled.Close, contentDescription = "Close") } - } else { - var deleteAllConfirmationDialog by remember { mutableStateOf(false) } - - if (deleteAllConfirmationDialog) { - Dialog(onDismissRequest = { deleteAllConfirmationDialog = false }) { - alertDialogs.ConfirmDialog( - title = "Are you sure you want to delete all your messages?", - message = "Warning: This action may flag your account for spam if used excessively.", - onDismiss = { - deleteAllConfirmationDialog = false - }, onConfirm = { - deletedMessageCount = 0 - deleteAllConfirmationDialog = false - messageDeleteJob = coroutineScope.launch(Dispatchers.IO) { - var lastMessageId = Long.MAX_VALUE - - do { - val fetchedMessages = messagingBridge.fetchConversationWithMessagesPaginated( - conversationId!!, - 100, - lastMessageId - ) - - if (fetchedMessages == null) { - context.shortToast("Failed to fetch messages") - return@launch - } - - if (fetchedMessages.isEmpty()) { - break - } - - fetchedMessages.forEach { - deleteIndividualMessage(it.serverMessageId) - delay(Random.nextLong(50, 170)) - } + } - lastMessageId = fetchedMessages.first().clientMessageId - } while (true) - }.apply { - invokeOnCompletion { - messageDeleteJob = null - context.shortToast("Successfully deleted $deletedMessageCount messages") - } + MaterialTheme( + colorScheme = MaterialTheme.colorScheme.copy( + surface = MaterialTheme.colorScheme.inverseSurface, + onSurface = MaterialTheme.colorScheme.inverseOnSurface + ), + shapes = MaterialTheme.shapes.copy(medium = RoundedCornerShape(50.dp)) + ) { + DropdownMenu( + expanded = showDropDown, onDismissRequest = { + showDropDown = false + } + ) { + val hasSelection = selectedMessages.isNotEmpty() + ActionButton(text = if (hasSelection) "Save selection" else "Save all", icon = Icons.Rounded.BookmarkAdded) { + triggerMessagingTask(MessagingTaskType.SAVE) + } + ActionButton(text = if (hasSelection) "Unsave selection" else "Unsave all", icon = Icons.Rounded.BookmarkBorder) { + triggerMessagingTask(MessagingTaskType.UNSAVE) + } + ActionButton(text = if (hasSelection) "Mark selected Snap as seen" else "Mark all Snaps as seen", icon = Icons.Rounded.RemoveRedEye) { + triggerMessagingTask(MessagingTaskType.READ, listOf( + MessagingConstraints.NO_USER_ID(myUserId), + MessagingConstraints.CONTENT_TYPE(ContentType.SNAP) + )) + } + ActionButton(text = if (hasSelection) "Delete selected" else "Delete all", icon = Icons.Rounded.DeleteForever) { + triggerMessagingTask(MessagingTaskType.DELETE, listOf(MessagingConstraints.USER_ID(myUserId))) { message -> + coroutineScope.launch { + messages.remove(message.serverMessageId) + messageSize = messages.size } - }) + } } } - - IconButton(onClick = { - deleteAllConfirmationDialog = true - }) { - Icon( - imageVector = Icons.Filled.DeleteForever, - contentDescription = "Delete" - ) - } } } @@ -224,7 +239,7 @@ class MessagingPreview( } } items(messageSize) {index -> - val elementKey = remember(index) { messages.entries.elementAt(index).key } + val elementKey = remember(index) { messages.entries.elementAt(index).value.clientMessageId } val messageReader = ProtoReader(messages.entries.elementAt(index).value.content) val contentType = ContentType.fromMessageContainer(messageReader) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/SocialSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/SocialSection.kt @@ -237,9 +237,7 @@ class SocialSection : Section() { fontWeight = FontWeight.Light ) } - Row( - verticalAlignment = Alignment.CenterVertically - ) { + Row(verticalAlignment = Alignment.CenterVertically) { if (streaks != null && streaks.notify) { Icon( imageVector = ImageVector.vectorResource(id = R.drawable.streak_icon), @@ -268,10 +266,7 @@ class SocialSection : Section() { MESSAGING_PREVIEW_ROUTE.replace("{id}", id).replace("{scope}", scope.key) ) }) { - Icon( - imageVector = Icons.Filled.RemoveRedEye, - contentDescription = null - ) + Icon(imageVector = Icons.Filled.RemoveRedEye, contentDescription = null) } } } 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 @@ -11,17 +11,25 @@ import me.rhunk.snapenhance.core.util.ktx.getObjectField import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC or FeatureLoadParams.INIT_ASYNC or FeatureLoadParams.INIT_SYNC) { - lateinit var conversationManager: Any + private var _conversationManager: Any? = null + val conversationManager: Any + get() = _conversationManager ?: throw IllegalStateException("ConversationManager is not initialized").also { + context.longToast("Failed to get conversation manager. Please restart Snapchat") + } var openedConversationUUID: SnapUUID? = null + private set var lastFetchConversationUserUUID: SnapUUID? = null + private set var lastFetchConversationUUID: SnapUUID? = null + private set var lastFetchGroupConversationUUID: SnapUUID? = null var lastFocusedMessageId: Long = -1 + private set override fun init() { Hooker.hookConstructor(context.classCache.conversationManager, HookStage.BEFORE) { - conversationManager = it.thisObject() + _conversationManager = it.thisObject() } }