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