fix: mark snaps as seen button for newer snap versions

This commit is contained in:
particle-box
2026-03-10 01:43:29 +05:30
parent 7f9b911f0b
commit 5ece2ec408
4 changed files with 289 additions and 60 deletions

View File

@@ -15,6 +15,7 @@ import androidx.compose.material.icons.outlined.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import me.eternal.purrfectsnap.bridge.DownloadCallback
import me.eternal.purrfectsnap.common.data.ContentType
import me.eternal.purrfectsnap.common.data.FileType
import me.eternal.purrfectsnap.common.data.MessagingRuleType
import me.eternal.purrfectsnap.common.data.download.*
@@ -66,6 +67,11 @@ class SnapChapterInfo(
val duration: Long?
)
data class OperaViewerMessageContext(
val conversationId: String,
val clientMessageId: Long
)
class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleType.AUTO_DOWNLOAD) {
private var lastSeenMediaInfoMap: MutableMap<SplitMediaAssetType, MediaInfo>? = null
var lastSeenMapParams: ParamMap? = null
@@ -195,6 +201,71 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
}
}
private fun isSnapContentType(contentTypeId: Int): Boolean {
return when (ContentType.fromId(contentTypeId)) {
ContentType.SNAP,
ContentType.TINY_SNAP,
ContentType.EXTERNAL_MEDIA -> true
else -> false
}
}
private fun validateViewerMessageContext(messageContext: OperaViewerMessageContext): OperaViewerMessageContext? {
val message = context.database.getConversationMessageFromId(messageContext.clientMessageId) ?: return null
if (message.clientConversationId != messageContext.conversationId) return null
if (!isSnapContentType(message.contentType)) return null
return messageContext
}
private fun parseViewerMessageContext(rawValue: String): OperaViewerMessageContext? {
val parts = rawValue.split(':')
if (parts.size < 3) return null
val conversationId = parts.firstOrNull()?.takeIf {
runCatching { UUID.fromString(it) }.isSuccess
} ?: return null
val clientMessageId = parts.lastOrNull()?.toLongOrNull() ?: return null
return OperaViewerMessageContext(
conversationId = conversationId,
clientMessageId = clientMessageId
)
}
fun resolveViewerMessageContextFromParamMap(paramMap: ParamMap? = lastSeenMapParams): OperaViewerMessageContext? {
if (paramMap == null) return null
paramMap["MESSAGE_ID"]?.toString()
?.let(::parseViewerMessageContext)
?.let(::validateViewerMessageContext)
?.let { return it }
return paramMap.concurrentHashMap.values
.asSequence()
.mapNotNull { value ->
value?.toString()?.let(::parseViewerMessageContext)
}
.mapNotNull(::validateViewerMessageContext)
.firstOrNull()
}
fun resolveCurrentSnapMessageContext(): OperaViewerMessageContext? {
val messaging = context.feature(Messaging::class)
val currentConversationId = messaging.openedConversationUUID?.toString()
val currentMessageId = messaging.lastFocusedMessageId.takeIf { it > 0L }
if (currentConversationId != null && currentMessageId != null) {
validateViewerMessageContext(
OperaViewerMessageContext(
conversationId = currentConversationId,
clientMessageId = currentMessageId
)
)?.let { return it }
}
return resolveViewerMessageContextFromParamMap()
}
private fun handleLocalReferences(path: String) = runBlocking {
Uri.parse(path).let { uri ->
if (uri.scheme == "file" || uri.scheme == null) {
@@ -285,9 +356,10 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
) {
// ─── Messages ─────────────────────────
paramMap["MESSAGE_ID"]?.toString()?.takeIf { forceDownload || shouldAutoDownload("friend_snaps") }?.let { id ->
val messageId = id.substringAfterLast(":").toLong()
val conversationMessage = context.database.getConversationMessageFromId(messageId) ?: return@let
resolveViewerMessageContextFromParamMap(paramMap)?.takeIf {
forceDownload || shouldAutoDownload("friend_snaps")
}?.let { messageContext ->
val conversationMessage = context.database.getConversationMessageFromId(messageContext.clientMessageId) ?: return@let
val conversationId = conversationMessage.clientConversationId!!
if (!forceDownload && !canUseRule(conversationId)) return@let
@@ -530,14 +602,26 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
}
val operaLayerList = (param.thisObject() as Any).getObjectField(layerListField.get()!!) as ArrayList<*>
val mediaParamMap: ParamMap = operaLayerList
val layerParamMaps = operaLayerList
.asSequence()
.mapNotNull { layerObj ->
layerObj?.let { runCatching { Layer(it).paramMap }.getOrNull() }
}
.firstOrNull {
it.containsKey("image_media_info") || it.containsKey("video_media_info_list")
} ?: return@onOperaViewStateCallback
.toList()
val firstLayerParamMap = layerParamMaps.firstOrNull()
val mediaParamMap: ParamMap = (
// Chat snaps need the primary MESSAGE_ID-bearing param map for mark-as-seen to work.
layerParamMaps.firstOrNull {
it.containsKey("MESSAGE_ID") &&
(it.containsKey("image_media_info") || it.containsKey("video_media_info_list"))
}
?: firstLayerParamMap?.takeIf {
it.containsKey("image_media_info") || it.containsKey("video_media_info_list")
}
?: layerParamMaps.firstOrNull {
it.containsKey("image_media_info") || it.containsKey("video_media_info_list")
}
) ?: return@onOperaViewStateCallback
val mediaInfoMap = mutableMapOf<SplitMediaAssetType, MediaInfo>()
val isVideo = mediaParamMap.containsKey("video_media_info_list")

View File

@@ -104,10 +104,9 @@ class OperaContextActionMenu : AbstractMenu() {
val playableStorySnapRecord = paramMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString()
val sentTimestamp = playableStorySnapRecord?.substringAfter("timestamp=")
?.substringBefore(",")?.toLongOrNull()
?: paramMap["MESSAGE_ID"]?.toString()?.let { messageId ->
?: mediaDownloader.resolveCurrentSnapMessageContext()?.clientMessageId?.let { messageId ->
context.database.getConversationMessageFromId(
messageId.substring(messageId.lastIndexOf(":") + 1)
.toLong()
messageId
)?.creationTimestamp
}
?: paramMap["SNAP_TIMESTAMP"]?.toString()?.toLongOrNull()

View File

@@ -6,41 +6,220 @@ import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.RemoveRedEye
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.eternal.purrfectsnap.common.ui.createComposeView
import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent
import me.eternal.purrfectsnap.core.event.events.impl.OnSnapInteractionEvent
import me.eternal.purrfectsnap.core.features.impl.downloader.MediaDownloader
import me.eternal.purrfectsnap.core.features.impl.downloader.OperaViewerMessageContext
import me.eternal.purrfectsnap.core.features.impl.messaging.AutoMarkAsRead
import me.eternal.purrfectsnap.core.ui.children
import me.eternal.purrfectsnap.core.ui.iterateParent
import me.eternal.purrfectsnap.core.ui.menu.AbstractMenu
import me.eternal.purrfectsnap.core.ui.randomTag
import me.eternal.purrfectsnap.core.ui.triggerCloseTouchEvent
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.util.ktx.getObjectField
import me.eternal.purrfectsnap.core.util.ktx.vibrateLongPress
import me.eternal.purrfectsnap.mapper.impl.OperaPageViewControllerMapper
class OperaViewerIcons : AbstractMenu() {
private val actionMenuIconSize by lazy { context.userInterface.dpToPx(32) }
private val actionMenuIconMargin by lazy { context.userInterface.dpToPx(5) }
private val actionMenuIconMarginTop by lazy { context.userInterface.dpToPx(10) }
private val injectedParentTag = randomTag()
private val viewerVisibleState = mutableStateOf(false)
private val viewerMessageContextState = mutableStateOf<OperaViewerMessageContext?>(null)
private val inlineMarkButtonVisibleState = mutableStateOf(false)
private var overlayRegistered = false
private var hooksInitialized = false
override fun init() {
if (hooksInitialized) return
hooksInitialized = true
registerOverlayFallback()
context.event.subscribe(OnSnapInteractionEvent::class) {
viewerMessageContextState.value = context.feature(MediaDownloader::class).resolveCurrentSnapMessageContext()
}
context.mappings.useMapper(OperaPageViewControllerMapper::class) {
arrayOf(onDisplayStateChange, onDisplayStateChangeGesture).forEach { methodName ->
classReference.get()?.hook(
methodName.get() ?: return@forEach,
HookStage.AFTER
) { param ->
val viewState = param.thisObject<Any>().getObjectField(viewStateField.get()!!).toString()
val isVisible = viewState == "FULLY_DISPLAYED"
viewerVisibleState.value = isVisible
if (!isVisible) {
viewerMessageContextState.value = null
inlineMarkButtonVisibleState.value = false
return@hook
}
viewerMessageContextState.value = context.feature(MediaDownloader::class).resolveCurrentSnapMessageContext()
}
}
}
}
private fun registerOverlayFallback() {
if (overlayRegistered) return
overlayRegistered = true
context.inAppOverlay.addCustomComposable {
val messageContext = viewerMessageContextState.value
if (
!context.config.messaging.markSnapAsSeenButton.get() ||
!viewerVisibleState.value ||
inlineMarkButtonVisibleState.value ||
messageContext == null
) return@addCustomComposable
Box(
modifier = Modifier
.fillMaxSize()
.padding(end = 18.dp, bottom = 118.dp),
contentAlignment = Alignment.BottomEnd
) {
Surface(
modifier = Modifier
.size(52.dp)
.clickable {
context.coroutineScope.launch {
markCurrentSnapAsSeen(parent = null)
}
},
shape = CircleShape,
color = Color.Black.copy(alpha = 0.55f)
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Default.RemoveRedEye,
tint = Color.White,
contentDescription = null
)
}
}
}
}
}
private fun Class<*>?.hasNameSuffixInHierarchy(suffix: String): Boolean {
var current = this
while (current != null) {
if (current.name.endsWith(suffix)) return true
current = current.superclass
}
return false
}
private fun shouldInjectIntoViewer(event: AddViewEvent): Boolean {
if (event.view !is FrameLayout) return false
if (!event.parent.javaClass.hasNameSuffixInHierarchy("OpenLayout")) return false
val viewGroup = event.view as? ViewGroup ?: return false
if (viewGroup.getTag(injectedParentTag) != null) return false
val hasOnlyImageChildren = viewGroup.childCount > 0 && viewGroup.children().all { it is ImageView }
val hasMaskFrameSibling = event.parent.children().any {
it.javaClass.hasNameSuffixInHierarchy("ScalableCircleMaskFrameLayout")
}
return hasOnlyImageChildren || hasMaskFrameSibling
}
private fun resolveCurrentMessageContext(mediaDownloader: MediaDownloader): OperaViewerMessageContext? {
return mediaDownloader.resolveCurrentSnapMessageContext()?.also {
viewerMessageContextState.value = it
}
}
private fun syncInlineMarkButtonVisibility(view: View, mediaDownloader: MediaDownloader) {
val isVisible = resolveCurrentMessageContext(mediaDownloader) != null
view.visibility = if (isVisible) View.VISIBLE else View.GONE
inlineMarkButtonVisibleState.value = isVisible
}
private suspend fun markCurrentSnapAsSeen(parent: ViewGroup?) {
val messageContext = resolveCurrentMessageContext(context.feature(MediaDownloader::class)) ?: return
val result = context.feature(AutoMarkAsRead::class).markSnapAsSeen(
messageContext.conversationId,
messageContext.clientMessageId
)
if (result == "DUPLICATEREQUEST" || result == null) {
if (context.config.messaging.skipWhenMarkingAsSeen.get()) {
withContext(Dispatchers.Main) {
if (parent != null) {
parent.iterateParent {
it.triggerCloseTouchEvent()
false
}
} else {
context.mainActivity
?.findViewById<View>(android.R.id.content)
?.triggerCloseTouchEvent()
}
}
}
}
if (result == "DUPLICATEREQUEST") return
if (result == null) {
context.inAppOverlay.showStatusToast(
Icons.Default.Info,
context.translation["mark_as_seen.seen_toast"],
durationMs = 800
)
} else {
context.inAppOverlay.showStatusToast(
Icons.Default.Info,
"Failed to mark as seen: $result",
)
}
}
override fun onViewAdded(event: AddViewEvent) {
if (event.view is FrameLayout && event.parent.javaClass.superclass?.name?.endsWith("OpenLayout") == true) {
val viewGroup = event.view as? ViewGroup ?: return
if (
viewGroup.childCount == 0 ||
viewGroup.children().any { it !is ImageView } ||
event.parent.children().none { it.javaClass.name.endsWith("ScalableCircleMaskFrameLayout") }
) return
inject(viewGroup)
}
if (!shouldInjectIntoViewer(event)) return
val viewGroup = event.view as? ViewGroup ?: return
viewGroup.setTag(injectedParentTag, true)
viewerVisibleState.value = true
viewGroup.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) {
viewerVisibleState.value = true
}
override fun onViewDetachedFromWindow(v: View) {
viewerVisibleState.value = false
inlineMarkButtonVisibleState.value = false
}
})
inject(viewGroup)
}
private fun inject(parent: ViewGroup) {
@@ -97,14 +276,6 @@ class OperaViewerIcons : AbstractMenu() {
}
if (context.config.messaging.markSnapAsSeenButton.get()) {
fun getMessageId(): Pair<String, String>? {
return mediaDownloader.lastSeenMapParams?.get("MESSAGE_ID")
?.toString()
?.split(":")
?.takeIf { it.size == 3 }
?.let { return it[0] to it[2] }
}
parent.addView(createComposeView(parent.context) {
Icon(
imageVector = Icons.Default.RemoveRedEye,
@@ -113,48 +284,23 @@ class OperaViewerIcons : AbstractMenu() {
)
}.apply {
setOnClickListener {
this@OperaViewerIcons.context.apply {
coroutineScope.launch {
val (conversationId, clientMessageId) = getMessageId() ?: return@launch
val result = feature(AutoMarkAsRead::class).markSnapAsSeen(conversationId, clientMessageId.toLong())
if (result == "DUPLICATEREQUEST" || result == null) {
if (config.messaging.skipWhenMarkingAsSeen.get()) {
withContext(Dispatchers.Main) {
parent.iterateParent {
it.triggerCloseTouchEvent()
false
}
}
}
}
if (result == "DUPLICATEREQUEST") return@launch
if (result == null) {
inAppOverlay.showStatusToast(
Icons.Default.Info,
translation["mark_as_seen.seen_toast"],
durationMs = 800
)
} else {
inAppOverlay.showStatusToast(
Icons.Default.Info,
"Failed to mark as seen: $result",
)
}
}
this@OperaViewerIcons.context.coroutineScope.launch {
markCurrentSnapAsSeen(parent)
}
}
addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) {
v.visibility = View.GONE
inlineMarkButtonVisibleState.value = false
this@OperaViewerIcons.context.coroutineScope.launch(Dispatchers.Main) {
delay(250)
v.visibility = if (getMessageId() != null) View.VISIBLE else View.GONE
syncInlineMarkButtonVisibility(v, mediaDownloader)
}
}
override fun onViewDetachedFromWindow(v: View) {}
override fun onViewDetachedFromWindow(v: View) {
inlineMarkButtonVisibleState.value = false
}
})
layoutParams = FrameLayout.LayoutParams(
@@ -169,4 +315,4 @@ class OperaViewerIcons : AbstractMenu() {
})
}
}
}
}