Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2135d66124 | ||
|
|
481e5840f0 | ||
|
|
216bb71fef | ||
|
|
181d19424f |
@@ -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)
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ class FeaturesRootSection : Routes.Route() {
|
||||
it.key.dataType.type == DataProcessors.Type.CONTAINER &&
|
||||
!it.key.params.flags.contains(ConfigFlag.HIDDEN)
|
||||
) {
|
||||
containers[it.key.name] = PropertyPair(it.key as PropertyKey<Any>, it.value as PropertyValue<Any>)
|
||||
containers[it.key.name] = (it.key to it.value).toPropertyPair()
|
||||
queryContainerRecursive(it.value.get() as ConfigContainer)
|
||||
}
|
||||
}
|
||||
@@ -286,7 +286,7 @@ class FeaturesRootSection : Routes.Route() {
|
||||
context.translation[it.key.propertyName()].contains(keyword, ignoreCase = true) ||
|
||||
context.translation[it.key.propertyDescription()].contains(keyword, ignoreCase = true)
|
||||
)
|
||||
}.map { PropertyPair(it.key as PropertyKey<Any>, it.value as PropertyValue<Any>) }
|
||||
}.map { (it.key to it.value).toPropertyPair() }
|
||||
|
||||
PropertiesView(
|
||||
properties = properties,
|
||||
@@ -1316,7 +1316,7 @@ class FeaturesRootSection : Routes.Route() {
|
||||
val isActiveSearch = isSearchResults || liveSearchQuery.isNotBlank()
|
||||
val globalSearchProperties = remember(enableGlobalSearch) {
|
||||
if (enableGlobalSearch) {
|
||||
allProperties.filter { isSearchVisibleProperty(it.key) }.map { PropertyPair(it.key as PropertyKey<Any>, it.value as PropertyValue<Any>) }
|
||||
allProperties.filter { isSearchVisibleProperty(it.key) }.map { (it.key to it.value).toPropertyPair() }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
@@ -1537,7 +1537,7 @@ class FeaturesRootSection : Routes.Route() {
|
||||
) {
|
||||
PropertiesView(
|
||||
properties = remember {
|
||||
configContainer.properties.map { PropertyPair(it.key as PropertyKey<Any>, it.value as PropertyValue<Any>) }.filter {
|
||||
configContainer.properties.map { (it.key to it.value).toPropertyPair() }.filter {
|
||||
!it.key.params.flags.contains(ConfigFlag.HIDDEN)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -6,10 +6,9 @@ import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.features.FeaturesRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.features.FeaturesRootSection.Companion.FEATURE_CONTAINER_ROUTE
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.features.FeaturesRootSection.Companion.SEARCH_FEATURE_ROUTE
|
||||
import me.eternal.purrfectsnap.common.config.PropertyKey
|
||||
import me.eternal.purrfectsnap.common.config.PropertyValue
|
||||
import me.eternal.purrfectsnap.common.config.PropertyPair
|
||||
import me.eternal.purrfectsnap.common.config.ConfigContainer
|
||||
import me.eternal.purrfectsnap.common.config.PropertyPair
|
||||
import me.eternal.purrfectsnap.common.config.toPropertyPair
|
||||
|
||||
@Composable
|
||||
fun FeaturesRootSection.AphelionFeaturesScreen(nav: NavBackStackEntry) {
|
||||
@@ -36,7 +35,7 @@ fun FeaturesRootSection.AphelionFeaturesScreen(nav: NavBackStackEntry) {
|
||||
context.translation[it.key.propertyName()].contains(keyword, ignoreCase = true) ||
|
||||
context.translation[it.key.propertyDescription()].contains(keyword, ignoreCase = true)
|
||||
)
|
||||
}.map { PropertyPair(it.key as PropertyKey<Any>, it.value as PropertyValue<Any>) }
|
||||
}.map { (it.key to it.value).toPropertyPair() }
|
||||
|
||||
PropertiesView(
|
||||
properties = properties,
|
||||
|
||||
@@ -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",
|
||||
@@ -2296,11 +2297,23 @@
|
||||
"schedule_failed": "Scheduled snap failed",
|
||||
"schedule_cancelled_for": "Cancelled for {name}",
|
||||
"device_model": {
|
||||
"samsung_s25_ultra": "Samsung Galaxy S25 Ultra",
|
||||
"google_pixel_10_pro": "Google Pixel 10 Pro",
|
||||
"oneplus_13": "OnePlus 13",
|
||||
"xiaomi_15_ultra": "Xiaomi 15 Ultra",
|
||||
"null": "Device Default"
|
||||
"none": "Device Default",
|
||||
"random": "Random",
|
||||
"Pixel 8 Pro": "Pixel 8 Pro",
|
||||
"Pixel 9 Pro XL": "Pixel 9 Pro XL",
|
||||
"Pixel 10": "Pixel 10",
|
||||
"Pixel 10 Pro": "Pixel 10 Pro",
|
||||
"Pixel 10 Pro XL": "Pixel 10 Pro XL",
|
||||
"Pixel 10 Pro Fold": "Pixel 10 Pro Fold",
|
||||
"Galaxy S23 Ultra": "Galaxy S23 Ultra",
|
||||
"Galaxy S24 Ultra": "Galaxy S24 Ultra",
|
||||
"Galaxy S25 Ultra": "Galaxy S25 Ultra",
|
||||
"OnePlus 15": "OnePlus 15",
|
||||
"OnePlus Open": "OnePlus Open",
|
||||
"Xiaomi 15 Ultra": "Xiaomi 15 Ultra",
|
||||
"OPPO Find X9 Pro": "OPPO Find X9 Pro",
|
||||
"vivo X100 Pro": "vivo X100 Pro",
|
||||
"realme GT 6": "realme GT 6"
|
||||
},
|
||||
"settings_menu": {
|
||||
"default": "Default",
|
||||
@@ -2754,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",
|
||||
|
||||
@@ -11,6 +11,10 @@ data class PropertyPair<T>(
|
||||
val name get() = key.name
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun Pair<PropertyKey<*>, PropertyValue<*>>.toPropertyPair(): PropertyPair<Any> =
|
||||
PropertyPair(first as PropertyKey<Any>, second as PropertyValue<Any>)
|
||||
|
||||
enum class FeatureNotice(
|
||||
val key: String
|
||||
) {
|
||||
|
||||
@@ -19,11 +19,24 @@ class Spoof : ConfigContainer(hasGlobalState = true) {
|
||||
val spoofDeviceId = container("spoof_device_id", SpoofDeviceIdConfig()) { requireRestart() }
|
||||
val spoofDevice = boolean("spoof_device") { requireRestart() }
|
||||
val deviceModel = unique("device_model",
|
||||
"samsung_s25_ultra",
|
||||
"google_pixel_10_pro",
|
||||
"oneplus_13",
|
||||
"xiaomi_15_ultra"
|
||||
) {
|
||||
"none",
|
||||
"random",
|
||||
"Pixel 8 Pro",
|
||||
"Pixel 9 Pro XL",
|
||||
"Pixel 10",
|
||||
"Pixel 10 Pro",
|
||||
"Pixel 10 Pro XL",
|
||||
"Pixel 10 Pro Fold",
|
||||
"Galaxy S23 Ultra",
|
||||
"Galaxy S24 Ultra",
|
||||
"Galaxy S25 Ultra",
|
||||
"OnePlus 15",
|
||||
"OnePlus Open",
|
||||
"Xiaomi 15 Ultra",
|
||||
"OPPO Find X9 Pro",
|
||||
"vivo X100 Pro",
|
||||
"realme GT 6"
|
||||
) {
|
||||
requireRestart()
|
||||
customOptionTranslationPath = "features.options.device_model"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
package me.eternal.purrfectsnap.core.features.impl.experiments
|
||||
|
||||
import java.security.SecureRandom
|
||||
|
||||
data class DeviceInfo(
|
||||
val manufacturer: String,
|
||||
val model: String,
|
||||
val brand: String,
|
||||
val device: String,
|
||||
val product: String,
|
||||
val hardware: String,
|
||||
val board: String,
|
||||
val bootloader: String,
|
||||
val display: String,
|
||||
val host: String
|
||||
)
|
||||
|
||||
object DeviceSpoofer {
|
||||
private val devices = mapOf(
|
||||
"Pixel 8 Pro" to DeviceInfo(
|
||||
manufacturer = "Google",
|
||||
model = "Pixel 8 Pro",
|
||||
brand = "google",
|
||||
device = "husky",
|
||||
product = "husky",
|
||||
hardware = "husky",
|
||||
board = "husky",
|
||||
bootloader = "husky-1.0-11003666",
|
||||
display = "UQ1A.231205.015",
|
||||
host = "abfarm-release-rbe-64-00163"
|
||||
),
|
||||
"Pixel 9 Pro XL" to DeviceInfo(
|
||||
manufacturer = "Google",
|
||||
model = "Pixel 9 Pro XL",
|
||||
brand = "google",
|
||||
device = "komodo",
|
||||
product = "komodo",
|
||||
hardware = "komodo",
|
||||
board = "komodo",
|
||||
bootloader = "komodo-1.0-12110753",
|
||||
display = "AP3A.241105.008",
|
||||
host = "abfarm-release-rbe-64-00163"
|
||||
),
|
||||
"Pixel 10" to DeviceInfo(
|
||||
manufacturer = "Google",
|
||||
model = "Pixel 10",
|
||||
brand = "google",
|
||||
device = "frankel",
|
||||
product = "frankel",
|
||||
hardware = "tensor_g5",
|
||||
board = "frankel",
|
||||
bootloader = "frankel-1.0-12345678",
|
||||
display = "BP1A.250105.002",
|
||||
host = "abfarm-release-rbe-65-00200"
|
||||
),
|
||||
"Pixel 10 Pro" to DeviceInfo(
|
||||
manufacturer = "Google",
|
||||
model = "Pixel 10 Pro",
|
||||
brand = "google",
|
||||
device = "blazer",
|
||||
product = "blazer",
|
||||
hardware = "tensor_g5",
|
||||
board = "blazer",
|
||||
bootloader = "blazer-1.0-12345679",
|
||||
display = "BP1A.250105.002",
|
||||
host = "abfarm-release-rbe-65-00201"
|
||||
),
|
||||
"Pixel 10 Pro XL" to DeviceInfo(
|
||||
manufacturer = "Google",
|
||||
model = "Pixel 10 Pro XL",
|
||||
brand = "google",
|
||||
device = "mustang",
|
||||
product = "mustang",
|
||||
hardware = "tensor_g5",
|
||||
board = "mustang",
|
||||
bootloader = "mustang-1.0-12345680",
|
||||
display = "BP1A.250105.002",
|
||||
host = "abfarm-release-rbe-65-00202"
|
||||
),
|
||||
"Pixel 10 Pro Fold" to DeviceInfo(
|
||||
manufacturer = "Google",
|
||||
model = "Pixel 10 Pro Fold",
|
||||
brand = "google",
|
||||
device = "rango",
|
||||
product = "rango",
|
||||
hardware = "tensor_g5",
|
||||
board = "rango",
|
||||
bootloader = "rango-1.0-12345681",
|
||||
display = "BP1A.250105.002",
|
||||
host = "abfarm-release-rbe-65-00203"
|
||||
),
|
||||
"Galaxy S23 Ultra" to DeviceInfo(
|
||||
manufacturer = "Samsung",
|
||||
model = "SM-S918B",
|
||||
brand = "samsung",
|
||||
device = "dm3q",
|
||||
product = "dm3qxx",
|
||||
hardware = "qcom",
|
||||
board = "kalama",
|
||||
bootloader = "S918BXXU3BWJM",
|
||||
display = "UP1A.231005.007.S918BXXU3BWJM",
|
||||
host = "21DH7R2P"
|
||||
),
|
||||
"Galaxy S24 Ultra" to DeviceInfo(
|
||||
manufacturer = "Samsung",
|
||||
model = "SM-S928B",
|
||||
brand = "samsung",
|
||||
device = "e9q",
|
||||
product = "e9qxx",
|
||||
hardware = "qcom",
|
||||
board = "pineapple",
|
||||
bootloader = "S928BXXU1AXB5",
|
||||
display = "UP1A.231005.007.S928BXXU1AXB5",
|
||||
host = "21DH7R2P"
|
||||
),
|
||||
"Galaxy S25 Ultra" to DeviceInfo(
|
||||
manufacturer = "Samsung",
|
||||
model = "SM-S938B",
|
||||
brand = "samsung",
|
||||
device = "e3q",
|
||||
product = "e3qxx",
|
||||
hardware = "qcom",
|
||||
board = "s5e9945",
|
||||
bootloader = "S938BXXU1AXL2",
|
||||
display = "UP1A.231005.007.S938BXXU1AXL2",
|
||||
host = "21DH7R2P"
|
||||
),
|
||||
"OnePlus 15" to DeviceInfo(
|
||||
manufacturer = "OnePlus",
|
||||
model = "CPH2651",
|
||||
brand = "OnePlus",
|
||||
device = "OP5929L1",
|
||||
product = "OP5929L1_EEA",
|
||||
hardware = "qcom",
|
||||
board = "taro",
|
||||
bootloader = "unknown",
|
||||
display = "CPH2651_15.0.0.503(EX01)",
|
||||
host = "ubuntu-build"
|
||||
),
|
||||
"OnePlus Open" to DeviceInfo(
|
||||
manufacturer = "OnePlus",
|
||||
model = "CPH2551",
|
||||
brand = "OnePlus",
|
||||
device = "OP594DL1",
|
||||
product = "OP594DL1_EEA",
|
||||
hardware = "qcom",
|
||||
board = "taro",
|
||||
bootloader = "unknown",
|
||||
display = "CPH2551_14.0.0.600(EX01)",
|
||||
host = "ubuntu-build"
|
||||
),
|
||||
"Xiaomi 15 Ultra" to DeviceInfo(
|
||||
manufacturer = "Xiaomi",
|
||||
model = "25010PN30G",
|
||||
brand = "Xiaomi",
|
||||
device = "xuanyuan",
|
||||
product = "xuanyuan_global",
|
||||
hardware = "qcom",
|
||||
board = "taro",
|
||||
bootloader = "unknown",
|
||||
display = "VK.15.0.3.0.VNGMIXM",
|
||||
host = "c3-miui-ota-bd164.bj"
|
||||
),
|
||||
"OPPO Find X9 Pro" to DeviceInfo(
|
||||
manufacturer = "OPPO",
|
||||
model = "PHY110",
|
||||
brand = "OPPO",
|
||||
device = "OP595DL1",
|
||||
product = "OP595DL1_EEA",
|
||||
hardware = "mt6989",
|
||||
board = "k6989v1_64",
|
||||
bootloader = "unknown",
|
||||
display = "PHY110_15.0.0.100(EX01)",
|
||||
host = "ubuntu-build-server"
|
||||
),
|
||||
"vivo X100 Pro" to DeviceInfo(
|
||||
manufacturer = "vivo",
|
||||
model = "V2309A",
|
||||
brand = "vivo",
|
||||
device = "V2309A",
|
||||
product = "PD2309",
|
||||
hardware = "mt6989",
|
||||
board = "k6989v1_64",
|
||||
bootloader = "unknown",
|
||||
display = "OP557L.PD2309.14.0.0.100",
|
||||
host = "compiler-server"
|
||||
),
|
||||
"realme GT 6" to DeviceInfo(
|
||||
manufacturer = "realme",
|
||||
model = "RMX3851",
|
||||
brand = "realme",
|
||||
device = "RMX3851",
|
||||
product = "RMX3851_11_A.13",
|
||||
hardware = "qcom",
|
||||
board = "taro",
|
||||
bootloader = "unknown",
|
||||
display = "RMX3851_14.0.0.700(EX01)",
|
||||
host = "ubuntu-server"
|
||||
)
|
||||
)
|
||||
|
||||
fun getAvailableDevices(): List<String> = devices.keys.toList()
|
||||
|
||||
fun getDeviceInfo(modelName: String): DeviceInfo? {
|
||||
return devices[modelName]
|
||||
}
|
||||
|
||||
fun generateFingerprint(deviceInfo: DeviceInfo, buildVersion: String): String {
|
||||
val id = "AP3A.${System.currentTimeMillis().toString().take(6)}.005"
|
||||
val incremental = System.nanoTime().toString().take(8)
|
||||
return "${deviceInfo.brand}/${deviceInfo.product}/${deviceInfo.device}:$buildVersion/$id/$incremental:user/release-keys"
|
||||
}
|
||||
|
||||
fun generateAndroidId(): String {
|
||||
val random = SecureRandom()
|
||||
val bytes = ByteArray(8)
|
||||
random.nextBytes(bytes)
|
||||
return bytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
fun androidIdToBytes(androidId: String): ByteArray {
|
||||
return try {
|
||||
val len = androidId.length
|
||||
val data = ByteArray(len / 2)
|
||||
var i = 0
|
||||
while (i < len) {
|
||||
data[i / 2] = ((Character.digit(androidId[i], 16) shl 4) + Character.digit(androidId[i + 1], 16)).toByte()
|
||||
i += 2
|
||||
}
|
||||
data
|
||||
} catch (e: Exception) {
|
||||
ByteArray(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,113 +16,82 @@ import java.security.SecureRandom
|
||||
|
||||
class DeviceSpooferHook: Feature("Device Spoofer") {
|
||||
private var spoofedAndroidId: String? = null
|
||||
private var hasLoggedId = false
|
||||
private var randomizedFingerprints = mutableMapOf<String, String>()
|
||||
private var spoofedDeviceInfo: DeviceInfo? = null
|
||||
private var spoofedFingerprint: String? = null
|
||||
|
||||
private fun generateAndroidId(): String {
|
||||
if (spoofedAndroidId != null) return spoofedAndroidId!!
|
||||
// Always check custom ID first - this ensures changes take effect immediately
|
||||
val customId = context.config.experimental.spoof.spoofDeviceId.customAndroidId.getNullable()
|
||||
if (!customId.isNullOrEmpty()) {
|
||||
spoofedAndroidId = customId.lowercase()
|
||||
if (!hasLoggedId) {
|
||||
context.log.info("Using custom Android ID: $spoofedAndroidId")
|
||||
hasLoggedId = true
|
||||
val normalizedId = customId.lowercase().trim()
|
||||
if (normalizedId.length == 16 && normalizedId.all { it in '0'..'9' || it in 'a'..'f' }) {
|
||||
// Only log when ID actually changes
|
||||
if (spoofedAndroidId != normalizedId) {
|
||||
spoofedAndroidId = normalizedId
|
||||
context.log.info("Using custom Android ID: $spoofedAndroidId")
|
||||
}
|
||||
return spoofedAndroidId!!
|
||||
} else {
|
||||
context.log.warn("Invalid custom Android ID format (must be 16 hex chars), generating new one")
|
||||
}
|
||||
return spoofedAndroidId!!
|
||||
}
|
||||
|
||||
// No custom ID set - use stored or generate new one
|
||||
val sharedPrefs = context.androidContext.getSharedPreferences("purrfectsnap_spoof", 0)
|
||||
spoofedAndroidId = sharedPrefs.getString("android_id", null)
|
||||
if (spoofedAndroidId == null) {
|
||||
spoofedAndroidId = generateRandomHexString(16)
|
||||
val storedId = sharedPrefs.getString("android_id", null)
|
||||
if (storedId == null || storedId.length != 16) {
|
||||
spoofedAndroidId = DeviceSpoofer.generateAndroidId()
|
||||
sharedPrefs.edit().putString("android_id", spoofedAndroidId).apply()
|
||||
context.log.info("Generated new Android ID: $spoofedAndroidId")
|
||||
} else if (!hasLoggedId) {
|
||||
context.log.info("Using stored Android ID: $spoofedAndroidId")
|
||||
} else {
|
||||
// Only use cached value if it matches stored value (handles regeneration)
|
||||
if (spoofedAndroidId != storedId) {
|
||||
spoofedAndroidId = storedId
|
||||
context.log.info("Using stored Android ID: $spoofedAndroidId")
|
||||
}
|
||||
}
|
||||
hasLoggedId = true
|
||||
return spoofedAndroidId!!
|
||||
}
|
||||
|
||||
private fun generateRandomHexString(length: Int): String {
|
||||
val random = SecureRandom()
|
||||
val bytes = ByteArray(length / 2)
|
||||
random.nextBytes(bytes)
|
||||
return bytes.joinToString("") { "%02x".format(it) }
|
||||
private fun getDeviceInfo(modelName: String): DeviceInfo? {
|
||||
return DeviceSpoofer.getDeviceInfo(modelName)
|
||||
}
|
||||
|
||||
private fun randomizeFingerprintBuildNumber(fingerprint: String, deviceKey: String): String {
|
||||
if (randomizedFingerprints.containsKey(deviceKey)) {
|
||||
return randomizedFingerprints[deviceKey]!!
|
||||
}
|
||||
val sharedPrefs = context.androidContext.getSharedPreferences("purrfectsnap_spoof", 0)
|
||||
val savedFingerprint = sharedPrefs.getString("fingerprint_$deviceKey", null)
|
||||
if (savedFingerprint != null) {
|
||||
randomizedFingerprints[deviceKey] = savedFingerprint
|
||||
return savedFingerprint
|
||||
}
|
||||
val parts = fingerprint.split("/")
|
||||
if (parts.size >= 3) {
|
||||
val buildPart = parts[2]
|
||||
val buildSections = buildPart.split(":")
|
||||
if (buildSections.size >= 2) {
|
||||
val buildDetails = buildSections[1].split("/")
|
||||
if (buildDetails.size >= 2) {
|
||||
val randomBuildNumber = generateRandomBuildNumber()
|
||||
val newBuildDetails = buildDetails.toMutableList()
|
||||
newBuildDetails[1] = randomBuildNumber
|
||||
val newBuildPart = "${buildSections[0]}:${newBuildDetails.joinToString("/")}"
|
||||
val newFingerprint = "${parts[0]}/${parts[1]}/$newBuildPart"
|
||||
randomizedFingerprints[deviceKey] = newFingerprint
|
||||
sharedPrefs.edit().putString("fingerprint_$deviceKey", newFingerprint).apply()
|
||||
return newFingerprint
|
||||
}
|
||||
private fun getSpoofedDeviceInfo(): DeviceInfo? {
|
||||
val selectedModel = context.config.experimental.spoof.deviceModel.getNullable() ?: return null
|
||||
if (selectedModel == "random") {
|
||||
val sharedPrefs = context.androidContext.getSharedPreferences("purrfectsnap_spoof", 0)
|
||||
val randomDevice = sharedPrefs.getString("random_device", null)
|
||||
if (randomDevice == null) {
|
||||
val availableDevices = DeviceSpoofer.getAvailableDevices()
|
||||
val newRandomDevice = availableDevices.random()
|
||||
sharedPrefs.edit().putString("random_device", newRandomDevice).apply()
|
||||
context.log.info("Randomly selected device: $newRandomDevice")
|
||||
spoofedDeviceInfo = getDeviceInfo(newRandomDevice)
|
||||
} else {
|
||||
context.log.info("Using stored random device: $randomDevice")
|
||||
spoofedDeviceInfo = getDeviceInfo(randomDevice)
|
||||
}
|
||||
return spoofedDeviceInfo
|
||||
}
|
||||
randomizedFingerprints[deviceKey] = fingerprint
|
||||
return fingerprint
|
||||
if (selectedModel == "none" || selectedModel == "null") return null
|
||||
spoofedDeviceInfo = getDeviceInfo(selectedModel)
|
||||
return spoofedDeviceInfo
|
||||
}
|
||||
|
||||
private fun generateRandomBuildNumber(): String {
|
||||
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
val random = SecureRandom()
|
||||
return (1..10).map { chars[random.nextInt(chars.length)] }.joinToString("")
|
||||
}
|
||||
|
||||
private fun randomizeDisplayId(display: String, deviceKey: String, buildNumber: String): String {
|
||||
private fun getSpoofedFingerprint(deviceInfo: DeviceInfo): String {
|
||||
val sharedPrefs = context.androidContext.getSharedPreferences("purrfectsnap_spoof", 0)
|
||||
val savedDisplay = sharedPrefs.getString("display_$deviceKey", null)
|
||||
if (savedDisplay != null) return savedDisplay
|
||||
val parts = display.split(".")
|
||||
val newDisplay = if (parts.size > 1) {
|
||||
"${parts[0]}.${parts[1]}.$buildNumber"
|
||||
val storedFingerprint = sharedPrefs.getString("device_fingerprint", null)
|
||||
if (storedFingerprint == null) {
|
||||
val buildVersion = Build.VERSION.RELEASE
|
||||
spoofedFingerprint = DeviceSpoofer.generateFingerprint(deviceInfo, buildVersion)
|
||||
sharedPrefs.edit().putString("device_fingerprint", spoofedFingerprint).apply()
|
||||
context.log.info("Generated new device fingerprint: $spoofedFingerprint")
|
||||
} else {
|
||||
display
|
||||
spoofedFingerprint = storedFingerprint
|
||||
context.log.info("Using stored device fingerprint: $spoofedFingerprint")
|
||||
}
|
||||
sharedPrefs.edit().putString("display_$deviceKey", newDisplay).apply()
|
||||
return newDisplay
|
||||
}
|
||||
|
||||
private fun getRandomSerial(deviceKey: String): String {
|
||||
val sharedPrefs = context.androidContext.getSharedPreferences("purrfectsnap_spoof", 0)
|
||||
val savedSerial = sharedPrefs.getString("serial_$deviceKey", null)
|
||||
if (savedSerial != null) return savedSerial
|
||||
val random = SecureRandom()
|
||||
val serial = (1..16).map {
|
||||
"0123456789ABCDEF"[random.nextInt(16)]
|
||||
}.joinToString("")
|
||||
sharedPrefs.edit().putString("serial_$deviceKey", serial).apply()
|
||||
return serial
|
||||
}
|
||||
|
||||
private fun getRandomBuildId(deviceKey: String): String {
|
||||
val sharedPrefs = context.androidContext.getSharedPreferences("purrfectsnap_spoof", 0)
|
||||
val savedBuildId = sharedPrefs.getString("build_id_$deviceKey", null)
|
||||
if (savedBuildId != null) return savedBuildId
|
||||
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
val random = SecureRandom()
|
||||
val buildId = (1..8).map { chars[random.nextInt(chars.length)] }.joinToString("")
|
||||
sharedPrefs.edit().putString("build_id_$deviceKey", buildId).apply()
|
||||
return buildId
|
||||
return spoofedFingerprint!!
|
||||
}
|
||||
|
||||
private fun getRandomGsfId(): String {
|
||||
@@ -165,65 +134,6 @@ class DeviceSpooferHook: Feature("Device Spoofer") {
|
||||
return mac
|
||||
}
|
||||
|
||||
data class DeviceProfile(
|
||||
val manufacturer: String,
|
||||
val brand: String,
|
||||
val model: String,
|
||||
val device: String,
|
||||
val product: String,
|
||||
val hardware: String,
|
||||
val board: String,
|
||||
val fingerprint: String,
|
||||
val display: String
|
||||
)
|
||||
|
||||
private val deviceProfiles = mapOf(
|
||||
"samsung_s25_ultra" to DeviceProfile(
|
||||
manufacturer = "samsung",
|
||||
brand = "samsung",
|
||||
model = "SM-S938U",
|
||||
device = "e3q",
|
||||
product = "e3qsqw",
|
||||
hardware = "qcom",
|
||||
board = "taro",
|
||||
fingerprint = "samsung/e3qsqw/e3q:15/AP2A.240805.005/S938USQU1AXL2:user/release-keys",
|
||||
display = "AP2A.240805.005.S938USQU1AXL2"
|
||||
),
|
||||
"google_pixel_10_pro" to DeviceProfile(
|
||||
manufacturer = "Google",
|
||||
brand = "google",
|
||||
model = "Pixel 10 Pro",
|
||||
device = "caiman",
|
||||
product = "caiman",
|
||||
hardware = "caiman",
|
||||
board = "caiman",
|
||||
fingerprint = "google/caiman/caiman:15/AP2A.240805.005/12345678:user/release-keys",
|
||||
display = "AP2A.240805.005"
|
||||
),
|
||||
"oneplus_13" to DeviceProfile(
|
||||
manufacturer = "OnePlus",
|
||||
brand = "OnePlus",
|
||||
model = "CPH2649",
|
||||
device = "OP5B41L1",
|
||||
product = "CPH2649_EEA",
|
||||
hardware = "qcom",
|
||||
board = "kalama",
|
||||
fingerprint = "OnePlus/CPH2649_EEA/OP5B41L1:15/SKQ1.240805.001/1730123456789:user/release-keys",
|
||||
display = "CPH2649_15.0.0.300(EX01)"
|
||||
),
|
||||
"xiaomi_15_ultra" to DeviceProfile(
|
||||
manufacturer = "Xiaomi",
|
||||
brand = "Xiaomi",
|
||||
model = "23127PN0CC",
|
||||
device = "aurora",
|
||||
product = "aurora_global",
|
||||
hardware = "qcom",
|
||||
board = "taro",
|
||||
fingerprint = "Xiaomi/aurora_global/aurora:14/UKQ1.231003.002/V816.0.7.0.UMLMIXM:user/release-keys",
|
||||
display = "UKQ1.231003.002"
|
||||
)
|
||||
)
|
||||
|
||||
private fun hookInstallerPackageName() {
|
||||
context.androidContext.packageManager::class.java.hook("getInstallerPackageName", HookStage.BEFORE) { param ->
|
||||
param.setResult("com.android.vending")
|
||||
@@ -232,6 +142,15 @@ class DeviceSpooferHook: Feature("Device Spoofer") {
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun init() {
|
||||
val spoofDevice by context.config.experimental.spoof.spoofDevice
|
||||
if (spoofDevice) {
|
||||
val deviceInfo = getSpoofedDeviceInfo()
|
||||
if (deviceInfo != null) {
|
||||
getSpoofedFingerprint(deviceInfo)
|
||||
context.log.info("Device spoofing initialized: ${deviceInfo.manufacturer} ${deviceInfo.model}")
|
||||
}
|
||||
}
|
||||
|
||||
if (LSPatchUpdater.HAS_LSPATCH) {
|
||||
hookInstallerPackageName()
|
||||
}
|
||||
@@ -380,66 +299,62 @@ class DeviceSpooferHook: Feature("Device Spoofer") {
|
||||
}
|
||||
}
|
||||
|
||||
val spoofDevice by context.config.experimental.spoof.spoofDevice
|
||||
if (spoofDevice) {
|
||||
val selectedDevice = context.config.experimental.spoof.deviceModel.getNullable() ?: "samsung_s25_ultra"
|
||||
val deviceProfile = deviceProfiles[selectedDevice] ?: deviceProfiles["samsung_s25_ultra"]!!
|
||||
val randomizedFingerprint = randomizeFingerprintBuildNumber(deviceProfile.fingerprint, selectedDevice)
|
||||
val buildNumber = randomizedFingerprint.split("/").getOrNull(2)?.split(":")?.getOrNull(1)?.split("/")?.getOrNull(1) ?: generateRandomBuildNumber()
|
||||
val randomizedDisplay = randomizeDisplayId(deviceProfile.display, selectedDevice, buildNumber)
|
||||
val randomSerial = getRandomSerial(selectedDevice)
|
||||
val randomBuildId = getRandomBuildId(selectedDevice)
|
||||
|
||||
context.log.info("Spoofing device as: ${deviceProfile.model}")
|
||||
val deviceInfo = getSpoofedDeviceInfo()
|
||||
if (deviceInfo != null) {
|
||||
val fingerprint = getSpoofedFingerprint(deviceInfo)
|
||||
|
||||
Build::class.java.apply {
|
||||
fields.forEach { field ->
|
||||
if (!field.isAccessible) field.isAccessible = true
|
||||
runCatching {
|
||||
val modifiersField = java.lang.reflect.Field::class.java.getDeclaredField("modifiers")
|
||||
modifiersField.isAccessible = true
|
||||
modifiersField.setInt(field, field.modifiers and java.lang.reflect.Modifier.FINAL.inv())
|
||||
}
|
||||
when (field.name) {
|
||||
"MANUFACTURER" -> field.set(null, deviceProfile.manufacturer)
|
||||
"BRAND" -> field.set(null, deviceProfile.brand)
|
||||
"MODEL" -> field.set(null, deviceProfile.model)
|
||||
"DEVICE" -> field.set(null, deviceProfile.device)
|
||||
"PRODUCT" -> field.set(null, deviceProfile.product)
|
||||
"HARDWARE" -> field.set(null, deviceProfile.hardware)
|
||||
"BOARD" -> field.set(null, deviceProfile.board)
|
||||
"FINGERPRINT" -> field.set(null, randomizedFingerprint)
|
||||
"DISPLAY" -> field.set(null, randomizedDisplay)
|
||||
"SERIAL" -> field.set(null, randomSerial)
|
||||
"ID" -> field.set(null, randomBuildId)
|
||||
"TAGS" -> field.set(null, "release-keys")
|
||||
"TYPE" -> field.set(null, "user")
|
||||
"USER" -> field.set(null, "android-build")
|
||||
"HOST" -> field.set(null, "build-host")
|
||||
context.log.info("Device spoofing active: ${deviceInfo.manufacturer} ${deviceInfo.model}")
|
||||
|
||||
Build::class.java.apply {
|
||||
fields.forEach { field ->
|
||||
if (!field.isAccessible) field.isAccessible = true
|
||||
runCatching {
|
||||
val modifiersField = java.lang.reflect.Field::class.java.getDeclaredField("modifiers")
|
||||
modifiersField.isAccessible = true
|
||||
modifiersField.setInt(field, field.modifiers and java.lang.reflect.Modifier.FINAL.inv())
|
||||
}
|
||||
when (field.name) {
|
||||
"MANUFACTURER" -> field.set(null, deviceInfo.manufacturer)
|
||||
"MODEL" -> field.set(null, deviceInfo.model)
|
||||
"BRAND" -> field.set(null, deviceInfo.brand)
|
||||
"DEVICE" -> field.set(null, deviceInfo.device)
|
||||
"PRODUCT" -> field.set(null, deviceInfo.product)
|
||||
"HARDWARE" -> field.set(null, deviceInfo.hardware)
|
||||
"FINGERPRINT" -> field.set(null, fingerprint)
|
||||
"BOARD" -> try { field.set(null, deviceInfo.board) } catch (_: Exception) {}
|
||||
"BOOTLOADER" -> try { field.set(null, deviceInfo.bootloader) } catch (_: Exception) {}
|
||||
"DISPLAY" -> try { field.set(null, deviceInfo.display) } catch (_: Exception) {}
|
||||
"HOST" -> try { field.set(null, deviceInfo.host) } catch (_: Exception) {}
|
||||
"TIME" -> try {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val randomDaysAgo = (30..180).random()
|
||||
val buildTime = currentTime - (randomDaysAgo * 24L * 60L * 60L * 1000L)
|
||||
field.setLong(null, buildTime)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
findClass("android.os.SystemProperties").apply {
|
||||
hook("get", HookStage.BEFORE) { param ->
|
||||
val key = param.arg<String>(0)
|
||||
when (key) {
|
||||
"ro.product.manufacturer", "ro.product.vendor.manufacturer", "ro.product.system.manufacturer", "ro.product.odm.manufacturer" -> param.setResult(deviceProfile.manufacturer)
|
||||
"ro.product.brand", "ro.product.vendor.brand", "ro.product.system.brand", "ro.product.odm.brand" -> param.setResult(deviceProfile.brand)
|
||||
"ro.product.model", "ro.product.vendor.model", "ro.product.system.model", "ro.product.odm.model" -> param.setResult(deviceProfile.model)
|
||||
"ro.product.device", "ro.product.vendor.device", "ro.product.system.device", "ro.product.odm.device" -> param.setResult(deviceProfile.device)
|
||||
"ro.product.name", "ro.product.vendor.name", "ro.product.system.name", "ro.product.odm.name" -> param.setResult(deviceProfile.product)
|
||||
"ro.hardware", "ro.hardware.chipname" -> param.setResult(deviceProfile.hardware)
|
||||
"ro.product.board" -> param.setResult(deviceProfile.board)
|
||||
"ro.build.fingerprint" -> param.setResult(randomizedFingerprint)
|
||||
"ro.build.display.id" -> param.setResult(randomizedDisplay)
|
||||
"ro.serialno", "ro.boot.serialno", "ril.serialnumber" -> param.setResult(randomSerial)
|
||||
"ro.build.id" -> param.setResult(randomBuildId)
|
||||
"ro.build.tags" -> param.setResult("release-keys")
|
||||
"ro.build.type" -> param.setResult("user")
|
||||
"ro.build.user" -> param.setResult("android-build")
|
||||
"ro.build.host" -> param.setResult("build-host")
|
||||
runCatching {
|
||||
findClass("android.os.SystemProperties").hook("get", HookStage.BEFORE) { param ->
|
||||
val key = param.argNullable<String>(0) ?: return@hook
|
||||
when (key) {
|
||||
"ro.product.manufacturer" -> param.setResult(deviceInfo.manufacturer)
|
||||
"ro.product.model" -> param.setResult(deviceInfo.model)
|
||||
"ro.product.brand" -> param.setResult(deviceInfo.brand)
|
||||
"ro.product.device" -> param.setResult(deviceInfo.device)
|
||||
"ro.product.name" -> param.setResult(deviceInfo.product)
|
||||
"ro.product.board" -> param.setResult(deviceInfo.board)
|
||||
"ro.hardware" -> param.setResult(deviceInfo.hardware)
|
||||
"ro.build.fingerprint" -> param.setResult(fingerprint)
|
||||
"ro.bootloader" -> param.setResult(deviceInfo.bootloader)
|
||||
"ro.build.display.id" -> param.setResult(deviceInfo.display)
|
||||
}
|
||||
}
|
||||
context.log.info("SystemProperties hooks installed successfully")
|
||||
}.onFailure {
|
||||
context.log.warn("Failed to hook SystemProperties: ${it.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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