diff --git a/.kotlin/sessions/kotlin-compiler-17792999147329205525.salive b/.kotlin/sessions/kotlin-compiler-17792999147329205525.salive new file mode 100644 index 00000000..e69de29b diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt index 93eec080..d376f298 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt @@ -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? = 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() val isVideo = mediaParamMap.containsKey("video_media_info_list") diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/OperaContextActionMenu.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/OperaContextActionMenu.kt index 64d19f1f..ff537bd4 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/OperaContextActionMenu.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/OperaContextActionMenu.kt @@ -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() diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/OperaViewerIcons.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/OperaViewerIcons.kt index e1614e10..44b029dc 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/OperaViewerIcons.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/OperaViewerIcons.kt @@ -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(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().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(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? { - 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() { }) } } -} \ No newline at end of file +}