diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/bridge/BridgeService.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/bridge/BridgeService.kt index ce48199c..9d56d813 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/bridge/BridgeService.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/bridge/BridgeService.kt @@ -30,11 +30,21 @@ class BridgeService : Service() { private lateinit var remoteSideContext: RemoteSideContext private var syncCallback: SyncCallback? = null var messagingBridge: MessagingBridge? = null + @Volatile + private var pendingSocialSnapshotCallback: ((List, List) -> Unit)? = null private fun clearSyncCallback() { syncCallback = null } + fun requestEphemeralSocialSnapshot(callback: (List, List) -> Unit) { + pendingSocialSnapshotCallback = callback + } + + fun clearEphemeralSocialSnapshotRequest() { + pendingSocialSnapshotCallback = null + } + override fun onDestroy() { clearSyncCallback() if (::remoteSideContext.isInitialized) { @@ -216,6 +226,11 @@ class BridgeService : Service() { remoteSideContext.log.verbose("Received ${groups.size} groups and ${friends.size} friends") val parsedFriends = friends.mapNotNull { toParcelable(it) } val parsedGroups = groups.mapNotNull { toParcelable(it) } + pendingSocialSnapshotCallback?.let { callback -> + pendingSocialSnapshotCallback = null + callback(parsedFriends, parsedGroups) + return + } remoteSideContext.database.replaceMessagingData(parsedFriends, parsedGroups) remoteSideContext.database.receiveMessagingDataCallback(parsedFriends, parsedGroups) } diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/AddFriendDialog.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/AddFriendDialog.kt index c9d494c3..d92272e3 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/AddFriendDialog.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/AddFriendDialog.kt @@ -219,19 +219,26 @@ class AddFriendDialog( var hasFetchError by remember { mutableStateOf(false) } LaunchedEffect(Unit) { - context.database.receiveMessagingDataCallback = { friends, groups -> - cachedFriends = friends.run { - if (pinnedIds != null) { - sortedBy { -pinnedIds.indexOf(it.userId) } - } else friends + val updateSnapshot: (List, List) -> Unit = { friends, groups -> + coroutineScope.launch { + cachedFriends = friends.run { + if (pinnedIds != null) { + sortedBy { -pinnedIds.indexOf(it.userId) } + } else friends + } + cachedGroups = groups.run { + if (pinnedIds != null) { + sortedBy { -pinnedIds.indexOf(it.conversationId) } + } else groups + } + timeoutJob?.cancel() + hasFetchError = false } - cachedGroups = groups.run { - if (pinnedIds != null) { - sortedBy { -pinnedIds.indexOf(it.conversationId) } - } else groups - } - timeoutJob?.cancel() - hasFetchError = false + } + if (context.bridgeService != null) { + context.bridgeService?.requestEphemeralSocialSnapshot(updateSnapshot) + } else { + context.database.receiveMessagingDataCallback = updateSnapshot } SnapWidgetBroadcastReceiverHelper.create(ReceiversConfig.BRIDGE_SYNC_ACTION) {}.also { runCatching { @@ -248,6 +255,13 @@ class AddFriendDialog( } } } + DisposableEffect(Unit) { + onDispose { + timeoutJob?.cancel() + context.bridgeService?.clearEphemeralSocialSnapshotRequest() + context.database.receiveMessagingDataCallback = { _, _ -> } + } + } me.eternal.purrfectsnap.ui.util.Dialog( onDismissRequest = { diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt index 7658dc1c..08432f2e 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt @@ -1095,7 +1095,6 @@ object LegacyTheme : ThemeContract { groupList = groups } updateScopeLists() - requestLatestSnapshot() } DisposableEffect(Unit) { onDispose { @@ -1223,6 +1222,7 @@ object LegacyTheme : ThemeContract { val listState = rememberLazyListState() var showConfirmDialog by remember { mutableStateOf(false) } var alsoDeleteFiles by remember { mutableStateOf(false) } + val hapticFeedback = LocalHapticFeedback.current LaunchedEffect(Unit) { fetchActiveTasks(this) @@ -1296,6 +1296,27 @@ object LegacyTheme : ThemeContract { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { + if (taskSelection.size > 1) { + val canMergeSelection by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(taskSelection.size)) { + taskSelection.all { it.second?.type?.contains("video") == true } + } + if (canMergeSelection) { + TopBarActionButton( + onClick = { + if (context.config.root.global.uiSettings.hapticFeedback.get()) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + } + mergeSelection( + taskSelection.toList() + .also { taskSelection.clear() } + .map { it.first to it.second!! } + ) + }, + icon = Icons.Filled.Merge, + text = translation["merge_button"] + ) + } + } Surface( shape = RoundedCornerShape(18.dp), color = Color.White.copy(alpha = 0.08f), diff --git a/build.gradle.kts b/build.gradle.kts index 1bf5e8a2..11b5bccf 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -33,8 +33,8 @@ tasks.register("getVersion") { } // You can still set these for legacy use by submodules or scripts: -rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.4.0").get()) -rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("280").get().toInt()) +rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.4.1").get()) +rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("281").get().toInt()) rootProject.ext.set("applicationId", "me.eternal.purrfectsnap") // buildHash: when PurrfectSnap or Snapchat is updated, mappings become outdated and auto-regenerate. // Include version code so each release has a different hash; use random for uniqueness within same version. diff --git a/changelogs-stable.txt b/changelogs-stable.txt index 69d92720..aa7a3acf 100644 --- a/changelogs-stable.txt +++ b/changelogs-stable.txt @@ -1,3 +1,13 @@ +## v1.4.1 +- New: Improved device spoofing, now you can create accounts & bypass login issue where login doesn't work(tq to RSR) +- Fix: Story Counter/Story Source position(tq to AhmedRaza) +- Fix: Better Location Overlay Icon for newer Snapchat versions +- Fix: Double tap to mark chat as read for newer Snapchat versions +- New: Mark Chat as Read(Friend feed menu) +- Fix: Opera Download button duplicating +- Fix: All friends getting automatically added even after unselecting in the social tab +- Fix: Spotlight Creator Info + ## v1.4.0 - Fix: Download profile picture button showing in all pages - Fix: Opera Download button & Mark Snaps as seen for newer snapchat versions diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 3a0964db..aba071fe 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -2290,6 +2290,7 @@ "stealth": "\ud83d\udc7b Stealth Mode", "auto_reply": "\ud83d\udce8 Auto Reply", "auto_delete_sent_messages": "\ud83d\uddd1\ufe0f Auto Delete Sent Messages", + "mark_chat_as_read": "\ud83d\udcd6 Mark Chat as Read", "mark_snaps_as_seen": "\ud83d\udc40 Mark Snaps as seen", "mark_stories_as_seen_locally": "\ud83d\udc40 Mark Stories as seen locally", "conversation_info": "\ud83d\udc64 Conversation Info", @@ -2776,6 +2777,8 @@ } }, "friend_menu_option": { + "mark_chat_as_read": "Mark Chat as Read", + "mark_chat_as_read_toast": "Marked chat as read!", "mark_snaps_as_seen": "Mark Snaps as seen", "mark_stories_as_seen_locally": "Mark Stories as seen locally", "preview": "Preview", diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/UserInterfaceTweaks.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/UserInterfaceTweaks.kt index c1ebc510..56cc5024 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/UserInterfaceTweaks.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/UserInterfaceTweaks.kt @@ -21,7 +21,7 @@ class UserInterfaceTweaks : ConfigContainer() { val friendFeedMenuButtons = multiple( - "friend_feed_menu_buttons","conversation_info", "mark_snaps_as_seen", "mark_stories_as_seen_locally", *MessagingRuleType.entries.filter { it.showInFriendMenu }.map { it.key }.toTypedArray() + "friend_feed_menu_buttons","conversation_info", "mark_chat_as_read", "mark_snaps_as_seen", "mark_stories_as_seen_locally", *MessagingRuleType.entries.filter { it.showInFriendMenu }.map { it.key }.toTypedArray() ).apply { set(mutableListOf("conversation_info", MessagingRuleType.STEALTH.key)) } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt index 09022b0c..5efd79ea 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt @@ -125,6 +125,7 @@ class FeatureManager( PreventForcedLogout(), ConversationToolbox(), SpotlightCommentsUsername(), + SpotlightCreatorInfo(), OperaStoryCounter(), OperaViewerParamsOverride(), StealthModeIndicator(), 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 71b87a1d..0f2473de 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 @@ -67,6 +67,8 @@ import me.eternal.purrfectsnap.core.ui.debugEditText 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.SNAPCHAT_13_80_VERSION +import me.eternal.purrfectsnap.core.util.isSnapchatVersionAtLeast import me.eternal.purrfectsnap.core.util.media.PreviewUtils import me.eternal.purrfectsnap.core.wrapper.impl.SnapUUID import me.eternal.purrfectsnap.core.wrapper.impl.media.MediaInfo @@ -112,6 +114,12 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp private val translations by lazy { context.translation.getCategory("download_processor") } + private val useModernOperaViewerContext by lazy { + isSnapchatVersionAtLeast( + context.mappings.getSnapchatPackageInfo()?.versionName, + SNAPCHAT_13_80_VERSION + ) + } fun provideDownloadManagerClient( mediaIdentifier: String, @@ -463,6 +471,19 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp return messageContext } + private fun resolveLegacyViewerMessageContext(paramMap: ParamMap? = lastSeenMapParams): OperaViewerMessageContext? { + val parts = paramMap?.get("MESSAGE_ID") + ?.toString() + ?.split(':') + ?.takeIf { it.size == 3 } + ?: return null + + return OperaViewerMessageContext( + conversationId = parts[0], + clientMessageId = parts[2].toLongOrNull() ?: return null + ) + } + private fun parseViewerMessageContext(rawValue: String): OperaViewerMessageContext? { val parts = rawValue.split(':') if (parts.size < 3) return null @@ -480,6 +501,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp fun resolveViewerMessageContextFromParamMap(paramMap: ParamMap? = lastSeenMapParams): OperaViewerMessageContext? { if (paramMap == null) return null + if (!useModernOperaViewerContext) return resolveLegacyViewerMessageContext(paramMap) paramMap["MESSAGE_ID"]?.toString() ?.let(::parseViewerMessageContext) @@ -496,6 +518,8 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp } fun resolveCurrentSnapMessageContext(): OperaViewerMessageContext? { + if (!useModernOperaViewerContext) return resolveLegacyViewerMessageContext() + val messaging = context.feature(Messaging::class) val currentConversationId = messaging.openedConversationUUID?.toString() val currentMessageId = messaging.lastFocusedMessageId.takeIf { it > 0L } @@ -855,19 +879,25 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp } .toList() val firstLayerParamMap = layerParamMaps.firstOrNull() - val mediaParamMap: ParamMap = ( - // Chat snaps need the primary MESSAGE_ID-bearing param map for mark-as-seen to work. + val mediaParamMap: ParamMap = if (useModernOperaViewerContext) { + ( + // 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") + } + ) + } else { layerParamMaps.firstOrNull { - it.containsKey("MESSAGE_ID") && - (it.containsKey("image_media_info") || it.containsKey("video_media_info_list")) + 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 + } ?: return@onOperaViewStateCallback val mediaInfoMap = mutableMapOf() val isVideo = mediaParamMap.containsKey("video_media_info_list") diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/BetterLocation.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/BetterLocation.kt index 7af376b1..f652a51a 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/BetterLocation.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/BetterLocation.kt @@ -211,6 +211,10 @@ class BetterLocation : Feature("Better Location") { } val mapViewId = context.resources.getId("mapview") + val statusBarHeight = context.resources.getIdentifier("status_bar_height", "dimen", "android") + .takeIf { it > 0 } + ?.let { context.resources.getDimensionPixelSize(it) } + ?: 0 if (context.config.global.betterLocation.showBatteryLevel.get()) { findClass("snap.snap_maps_sdk.nano.SnapMapsSdk\$PublicUserInfo").hook("setDisplayName", HookStage.BEFORE) { param -> @@ -259,8 +263,8 @@ class BetterLocation : Feature("Better Location") { }.apply { layoutParams = RelativeLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { addRule(RelativeLayout.ALIGN_PARENT_LEFT) - // Keep the button below the top map chips (Memories/Visited/Popular/Favorites). - setMargins(0, (88 * context.resources.displayMetrics.density).toInt(), 0, 0) + // Keep the button below the map chips and clear the status bar area on taller layouts. + setMargins(0, statusBarHeight + this@BetterLocation.context.userInterface.dpToPx(84), 0, 0) } }) } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/MediaFilePicker.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/MediaFilePicker.kt index 27e9cf43..a5602bcb 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/MediaFilePicker.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/MediaFilePicker.kt @@ -37,10 +37,10 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch +import me.eternal.purrfectsnap.common.data.FileType import me.eternal.purrfectsnap.common.ui.createComposeView import me.eternal.purrfectsnap.common.util.ktx.getLongOrNull import me.eternal.purrfectsnap.common.util.ktx.getTypeArguments -import me.eternal.purrfectsnap.common.data.FileType import me.eternal.purrfectsnap.core.event.events.impl.ActivityResultEvent import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent import me.eternal.purrfectsnap.core.features.Feature @@ -108,7 +108,7 @@ class MediaFilePicker : Feature("Media File Picker") { val handlerParamMethod = contextType.methods.firstOrNull { method -> method.parameterTypes.size == 1 && ( method.parameterTypes[0].name.endsWith("ChatMediaDrawerActionHandler") || - actionHandlerCls.isAssignableFrom(method.parameterTypes[0]) + actionHandlerCls.isAssignableFrom(method.parameterTypes[0]) ) } ?: return@useMapper val sendItems = handlerParamMethod.parameterTypes[0].methods.firstOrNull { it.name == sendItemsName } ?: return@useMapper @@ -127,7 +127,7 @@ class MediaFilePicker : Feature("Media File Picker") { val uri = param.arg(0) if (!uri.toString().endsWith(firstVideoId.toString())) return@hook - param.setResult(object: CursorWrapper(param.getResult() as Cursor) { + param.setResult(object : CursorWrapper(param.getResult() as Cursor) { override fun getLong(columnIndex: Int): Long { if (getColumnName(columnIndex) == "duration") { return lastMediaDuration ?: -1 @@ -269,7 +269,7 @@ class MediaFilePicker : Feature("Media File Picker") { context.event.subscribe(AddViewEvent::class) { event -> if (event.parent !is FrameLayout || drawerViewClass?.isInstance(event.view) != true) return@subscribe - event.view.addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener { + event.view.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { override fun onViewAttachedToWindow(v: View) { if (event.parent.findViewWithTag(buttonTag)?.run { visibility = View.VISIBLE @@ -345,6 +345,7 @@ class MediaFilePicker : Feature("Media File Picker") { } ) } + override fun onViewDetachedFromWindow(v: View) { event.parent.findViewWithTag(buttonTag)?.visibility = View.GONE } @@ -352,5 +353,4 @@ class MediaFilePicker : Feature("Media File Picker") { } } } - } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/tweaks/DoubleTapChatAction.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/tweaks/DoubleTapChatAction.kt index 05e092b5..be63ce93 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/tweaks/DoubleTapChatAction.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/tweaks/DoubleTapChatAction.kt @@ -1,66 +1,173 @@ package me.eternal.purrfectsnap.core.features.impl.tweaks +import android.os.SystemClock +import android.view.MotionEvent +import android.view.View +import android.view.ViewConfiguration +import kotlinx.coroutines.launch import me.eternal.purrfectsnap.common.data.ContentType import me.eternal.purrfectsnap.common.data.MessageUpdate import me.eternal.purrfectsnap.common.util.ktx.copyToClipboard import me.eternal.purrfectsnap.common.util.ktx.findFieldsToString import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader import me.eternal.purrfectsnap.core.features.Feature +import me.eternal.purrfectsnap.core.features.impl.messaging.AutoMarkAsRead import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging +import me.eternal.purrfectsnap.core.features.impl.spying.StealthMode +import me.eternal.purrfectsnap.core.ui.getValdiContext import me.eternal.purrfectsnap.core.util.hook.HookStage import me.eternal.purrfectsnap.core.util.hook.hook import me.eternal.purrfectsnap.core.wrapper.impl.getMessageText import me.eternal.purrfectsnap.mapper.impl.ChatEventDispatcherMapper class DoubleTapChatAction: Feature("Double Tap Chat Action") { + private data class ChatDoubleTapTarget( + val conversationId: String, + val messageId: Long + ) + + private val messageIdPattern = Regex("([0-9a-fA-F-]{36}):[^,\\s:]+:(\\d+)") + private var lastTapTarget: ChatDoubleTapTarget? = null + private var lastTapAt = 0L + private var lastTapDownTime = -1L + private var lastHandledTarget: ChatDoubleTapTarget? = null + private var lastHandledAt = 0L + + private fun resolveTarget(rawValue: String?): ChatDoubleTapTarget? { + val match = rawValue?.let { messageIdPattern.find(it) } ?: return null + val conversationId = match.groupValues[1] + val messageId = match.groupValues[2].toLongOrNull() ?: return null + val message = context.database.getConversationMessageFromId(messageId) ?: return null + if (message.clientConversationId != conversationId) return null + return ChatDoubleTapTarget(conversationId, messageId) + } + + private fun resolveTargetFromDispatcherEvent(event: Any): ChatDoubleTapTarget? { + resolveTarget(event.toString())?.let { return it } + val field = event.javaClass.findFieldsToString(event, once = true) { _, value -> + value.contains("ChatViewModel") || messageIdPattern.containsMatchIn(value) + }.firstOrNull() ?: return null + return resolveTarget(field.get(event)?.toString()) + } + + private fun resolveTargetFromView(view: View): ChatDoubleTapTarget? { + val valdiContext = view.getValdiContext() ?: return null + return sequenceOf( + valdiContext.viewModel, + valdiContext.viewModelLegacy, + valdiContext.componentContext?.get() + ).mapNotNull { candidate -> + resolveTarget(candidate?.toString()) + }.firstOrNull() + } + + private fun executeAction(action: String, target: ChatDoubleTapTarget) { + if ( + lastHandledTarget == target && + SystemClock.uptimeMillis() - lastHandledAt <= ViewConfiguration.getDoubleTapTimeout().toLong() + ) { + return + } + + lastHandledTarget = target + lastHandledAt = SystemClock.uptimeMillis() + + if (action == "like_message") { + context.feature(Messaging::class).conversationManager?.reactToMessage( + target.conversationId, + target.messageId, + intentionType = 1L, + onError = {}, + onSuccess = {} + ) + } + + if (action == "copy_text") { + val messageContent = context.database.getConversationMessageFromId(target.messageId)?.messageContent ?: return + val proto = ProtoReader(messageContent).followPath(4, 4) ?: return + context.androidContext.copyToClipboard( + proto.getBuffer().getMessageText(ContentType.fromMessageContainer(proto) ?: ContentType.CHAT) ?: return, + "Chat Message" + ) + } + + if (action == "delete_message") { + context.feature(Messaging::class).conversationManager?.updateMessage( + target.conversationId, + target.messageId, + MessageUpdate.ERASE, + onResult = {} + ) + } + + if (action == "mark_as_read") { + val message = context.database.getConversationMessageFromId(target.messageId) ?: return + when (ContentType.fromId(message.contentType)) { + ContentType.SNAP, + ContentType.TINY_SNAP, + ContentType.EXTERNAL_MEDIA -> { + context.coroutineScope.launch { + context.feature(AutoMarkAsRead::class).markSnapAsSeen(target.conversationId, target.messageId) + } + } + else -> { + context.feature(StealthMode::class).addDisplayedMessageException(target.messageId) + context.feature(Messaging::class).conversationManager?.displayedMessages( + target.conversationId, + target.messageId, + onResult = { + if (it != null) { + context.log.error("Failed to mark conversation as read: $it") + context.shortToast(context.translation["toast_mark_conversation_read_failed"]) + } + } + ) + } + } + } + + if (action == "custom_emoji_reaction") { + context.feature(Messaging::class).conversationManager?.reactToMessage( + target.conversationId, + target.messageId, + emoji = context.config.messaging.doubleTapChatActionCustomEmoji.getNullable()?.takeIf { it.isNotEmpty() } ?: "\uD83D\uDC4D", + onError = {}, + onSuccess = {} + ) + } + } + override fun init() { var action = context.config.messaging.doubleTapChatAction.getNullable() ?: return context.mappings.useMapper(ChatEventDispatcherMapper::class) { classReference.getAsClass()?.hook("onChatItemDoubleClickEvent", HookStage.BEFORE) { param -> param.setResult(null) - val event = param.arg(0) - val viewModel = event.javaClass.findFieldsToString(event, once = true) { field, value -> value.contains("ChatViewModel") }.firstOrNull()?.get(event)?.toString() ?: return@hook - - val (conversationId, _, clientMessageId) = viewModel.substringAfter("messageId=").substringBefore(",").split(":").takeIf { it.size == 3 } ?: return@hook - - val messageId = clientMessageId.toLongOrNull() ?: return@hook - - if (action == "like_message") { - context.feature(Messaging::class).conversationManager?.reactToMessage( - conversationId, - messageId, - intentionType = 1L, - onError = {}, - onSuccess = {} - ) - } - - if (action == "copy_text") { - var messageContent = context.database.getConversationMessageFromId(messageId)?.messageContent ?: return@hook - var proto = ProtoReader(messageContent).followPath(4, 4) ?: return@hook - context.androidContext.copyToClipboard(proto.getBuffer().getMessageText(ContentType.fromMessageContainer(proto) ?: ContentType.CHAT) ?: return@hook, "Chat Message") - } - - if (action == "delete_message" || action == "mark_as_read") { - context.feature(Messaging::class).conversationManager?.updateMessage( - conversationId, - messageId, - if (action == "delete_message") MessageUpdate.ERASE else MessageUpdate.READ, - onResult = {} - ) - } - - if (action == "custom_emoji_reaction") { - context.feature(Messaging::class).conversationManager?.reactToMessage( - conversationId, - messageId, - emoji = context.config.messaging.doubleTapChatActionCustomEmoji.getNullable()?.takeIf { it.isNotEmpty() } ?: "\uD83D\uDC4D", - onError = {}, - onSuccess = {} - ) - } + resolveTargetFromDispatcherEvent(param.arg(0))?.let { executeAction(action, it) } } } + + View::class.java.hook("dispatchTouchEvent", HookStage.BEFORE) { param -> + val motionEvent = param.arg(0) + if (motionEvent.actionMasked != MotionEvent.ACTION_UP) return@hook + if (motionEvent.eventTime - motionEvent.downTime > ViewConfiguration.getTapTimeout()) return@hook + if (lastTapDownTime == motionEvent.downTime) return@hook + + val target = resolveTargetFromView(param.thisObject()) ?: return@hook + val now = SystemClock.uptimeMillis() + val isSecondTap = lastTapTarget == target && + now - lastTapAt <= ViewConfiguration.getDoubleTapTimeout().toLong() + + lastTapDownTime = motionEvent.downTime + if (isSecondTap) { + executeAction(action, target) + lastTapTarget = null + lastTapAt = 0L + return@hook + } + + lastTapTarget = target + lastTapAt = now + } } -} \ No newline at end of file +} diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryCounter.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryCounter.kt index a442463a..b515d0f9 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryCounter.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryCounter.kt @@ -113,9 +113,14 @@ class OperaStoryCounter : Feature("OperaStoryCounter") { FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.WRAP_CONTENT ).apply { - gravity = Gravity.TOP or Gravity.END + val isOperaDownloadEnabled = this@OperaStoryCounter.context.config.downloader.operaDownloadButton.get() + gravity = Gravity.TOP or if (isOperaDownloadEnabled) Gravity.START else Gravity.END topMargin = this@OperaStoryCounter.context.userInterface.dpToPx(50) - marginEnd = this@OperaStoryCounter.context.userInterface.dpToPx(10) + if (isOperaDownloadEnabled) { + marginStart = this@OperaStoryCounter.context.userInterface.dpToPx(10) + } else { + marginEnd = this@OperaStoryCounter.context.userInterface.dpToPx(10) + } } } viewGroup.addView(composeView) diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/FriendFeedInfoMenu.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/FriendFeedInfoMenu.kt index e84f59d3..ebd8e63c 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/FriendFeedInfoMenu.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/FriendFeedInfoMenu.kt @@ -55,6 +55,7 @@ import me.eternal.purrfectsnap.core.features.impl.experiments.EndToEndEncryption import me.eternal.purrfectsnap.core.features.impl.messaging.AutoMarkAsRead import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging import me.eternal.purrfectsnap.core.features.impl.spying.MessageLogger +import me.eternal.purrfectsnap.core.features.impl.spying.StealthMode import me.eternal.purrfectsnap.core.ui.ViewAppearanceHelper import me.eternal.purrfectsnap.core.ui.children import me.eternal.purrfectsnap.core.ui.PurrfectGlassCard @@ -649,6 +650,41 @@ class FriendFeedInfoMenu : AbstractMenu() { ) } + if (friendFeedMenuOptions.contains("mark_chat_as_read")) { + MenuElement( + remember { elementIndex++ }, + Icons.Outlined.MarkChatRead, + translation["mark_chat_as_read"], + onClick = { + context.apply { + closeMenu() + val latestMessageId = database.getMessagesFromConversationId(conversationId, 1) + ?.firstOrNull() + ?.clientMessageId + ?.toLong() + ?: return@apply + + feature(StealthMode::class).addDisplayedMessageException(latestMessageId) + feature(Messaging::class).conversationManager?.displayedMessages( + conversationId, + latestMessageId + ) { error -> + if (error != null) { + log.error("Failed to mark chat as read: $error") + shortToast(this.translation["toast_mark_conversation_read_failed"]) + } else { + inAppOverlay.showStatusToast( + Icons.Default.Info, + translation["mark_chat_as_read_toast"], + durationMs = 1800 + ) + } + } + } + } + ) + } + if (targetUser != null && friendFeedMenuOptions.contains("mark_stories_as_seen_locally")) { val markAsSeenTranslation = remember { context.translation.getCategory("mark_as_seen") } 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 44b029dc..0838b3fb 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 @@ -38,9 +38,11 @@ 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.SNAPCHAT_13_80_VERSION 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.isSnapchatVersionAtLeast import me.eternal.purrfectsnap.core.util.ktx.vibrateLongPress import me.eternal.purrfectsnap.mapper.impl.OperaPageViewControllerMapper @@ -54,8 +56,15 @@ class OperaViewerIcons : AbstractMenu() { private val inlineMarkButtonVisibleState = mutableStateOf(false) private var overlayRegistered = false private var hooksInitialized = false + private val useModernViewerBehavior by lazy { + isSnapchatVersionAtLeast( + context.mappings.getSnapchatPackageInfo()?.versionName, + SNAPCHAT_13_80_VERSION + ) + } override fun init() { + if (!useModernViewerBehavior) return if (hooksInitialized) return hooksInitialized = true @@ -205,6 +214,18 @@ class OperaViewerIcons : AbstractMenu() { } override fun onViewAdded(event: AddViewEvent) { + if (!useModernViewerBehavior) { + 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) + } + return + } if (!shouldInjectIntoViewer(event)) return val viewGroup = event.view as? ViewGroup ?: return viewGroup.setTag(injectedParentTag, true) @@ -276,6 +297,45 @@ class OperaViewerIcons : AbstractMenu() { } if (context.config.messaging.markSnapAsSeenButton.get()) { + if (!useModernViewerBehavior) { + parent.addView(createComposeView(parent.context) { + Icon( + imageVector = Icons.Default.RemoveRedEye, + tint = Color.White, + contentDescription = null + ) + }.apply { + setOnClickListener { + this@OperaViewerIcons.context.coroutineScope.launch { + markCurrentSnapAsSeen(parent) + } + } + + addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) { + v.visibility = View.GONE + this@OperaViewerIcons.context.coroutineScope.launch(Dispatchers.Main) { + delay(250) + v.visibility = if (resolveCurrentMessageContext(mediaDownloader) != null) View.VISIBLE else View.GONE + } + } + + override fun onViewDetachedFromWindow(v: View) {} + }) + + layoutParams = FrameLayout.LayoutParams( + (actionMenuIconSize * 1.5).toInt(), + (actionMenuIconSize * 1.5).toInt() + ).apply { + setMargins(0, 0, 0, actionMenuIconMarginTop * 2 + this@OperaViewerIcons.context.userInterface.dpToPx(80)) + marginEnd = actionMenuIconMarginTop * 2 + marginStart = actionMenuIconMarginTop * 2 + gravity = Gravity.BOTTOM or Gravity.END + } + }) + return + } + parent.addView(createComposeView(parent.context) { Icon( imageVector = Icons.Default.RemoveRedEye, diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/util/SnapchatVersion.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/util/SnapchatVersion.kt new file mode 100644 index 00000000..2a2e95b4 --- /dev/null +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/util/SnapchatVersion.kt @@ -0,0 +1,33 @@ +package me.eternal.purrfectsnap.core.util + +const val SNAPCHAT_13_80_VERSION = "13.80.0.0" + +private fun parseSnapchatVersion(versionName: String?): List? { + val normalizedVersion = versionName + ?.substringBefore('-') + ?.substringBefore(' ') + ?.takeIf { it.isNotBlank() } + ?: return null + + return normalizedVersion.split('.') + .mapNotNull { segment -> + segment.filter(Char::isDigit).takeIf { it.isNotBlank() }?.toIntOrNull() + } + .takeIf { it.isNotEmpty() } +} + +fun isSnapchatVersionAtLeast(versionName: String?, minimumVersion: String): Boolean { + val current = parseSnapchatVersion(versionName) ?: return false + val minimum = parseSnapchatVersion(minimumVersion) ?: return false + val maxLength = maxOf(current.size, minimum.size) + + for (index in 0 until maxLength) { + val currentPart = current.getOrElse(index) { 0 } + val minimumPart = minimum.getOrElse(index) { 0 } + if (currentPart != minimumPart) { + return currentPart > minimumPart + } + } + + return true +} diff --git a/gradle.properties b/gradle.properties index 79eec1cc..53025f90 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,8 +7,8 @@ org.gradle.configuration-cache=true org.gradle.configuration-cache.problems=warn nativeAbis=arm64-v8a -APP_VERSION_NAME=1.4.0 -APP_VERSION_CODE=280 +APP_VERSION_NAME=1.4.1 +APP_VERSION_CODE=281 debug_build_hash=18fe2a814d0e2eb5 psIntegrityPinnedSha256= EXPECTED_CERT_SHA256=0f188cb17d8ea4902ee15ce98f4928ba4226a4790fa3c52f62d776b08e46ca1c