many fixes & improvements(v1.4.1)
This commit is contained in:
@@ -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<MessagingFriendInfo>, List<MessagingGroupInfo>) -> Unit)? = null
|
||||
|
||||
private fun clearSyncCallback() {
|
||||
syncCallback = null
|
||||
}
|
||||
|
||||
fun requestEphemeralSocialSnapshot(callback: (List<MessagingFriendInfo>, List<MessagingGroupInfo>) -> 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<MessagingFriendInfo>(it) }
|
||||
val parsedGroups = groups.mapNotNull { toParcelable<MessagingGroupInfo>(it) }
|
||||
pendingSocialSnapshotCallback?.let { callback ->
|
||||
pendingSocialSnapshotCallback = null
|
||||
callback(parsedFriends, parsedGroups)
|
||||
return
|
||||
}
|
||||
remoteSideContext.database.replaceMessagingData(parsedFriends, parsedGroups)
|
||||
remoteSideContext.database.receiveMessagingDataCallback(parsedFriends, parsedGroups)
|
||||
}
|
||||
|
||||
@@ -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<MessagingFriendInfo>, List<MessagingGroupInfo>) -> 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 = {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -33,8 +33,8 @@ tasks.register<GetVersionTask>("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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2280,6 +2280,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",
|
||||
@@ -2766,6 +2767,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",
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -125,6 +125,7 @@ class FeatureManager(
|
||||
PreventForcedLogout(),
|
||||
ConversationToolbox(),
|
||||
SpotlightCommentsUsername(),
|
||||
SpotlightCreatorInfo(),
|
||||
OperaStoryCounter(),
|
||||
OperaViewerParamsOverride(),
|
||||
StealthModeIndicator(),
|
||||
|
||||
@@ -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<SplitMediaAssetType, MediaInfo>()
|
||||
val isVideo = mediaParamMap.containsKey("video_media_info_list")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<Uri>(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<View>(buttonTag)?.run {
|
||||
visibility = View.VISIBLE
|
||||
@@ -345,6 +345,7 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onViewDetachedFromWindow(v: View) {
|
||||
event.parent.findViewWithTag<View>(buttonTag)?.visibility = View.GONE
|
||||
}
|
||||
@@ -352,5 +353,4 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<Any>(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<MotionEvent>(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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") }
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Int>? {
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user