refactor(manager/social): messaging task

This commit is contained in:
rhunk 2023-10-15 13:32:39 +02:00
parent 50a43ee6ad
commit 9f098834cf
4 changed files with 268 additions and 125 deletions

View File

@ -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)
}
}

View File

@ -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?) }
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 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
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,93 +157,51 @@ 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)

View File

@ -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)
}
}
}

View File

@ -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()
}
}