Merge pull request #129 from imCrest/dev

Sync: Latest improvements and multiple fixes by Sujal
This commit is contained in:
ᴋᴀʟᴀᴅɪɴ
2026-04-17 23:55:39 +05:30
committed by GitHub
10 changed files with 402 additions and 13 deletions

View File

@@ -779,11 +779,14 @@ class FeaturesRootSection : Routes.Route() {
DataProcessors.Type.STRING, DataProcessors.Type.INTEGER, DataProcessors.Type.FLOAT -> {
val isMessageListProperty = property.key.name.endsWith("_messages")
val isSleepWindowProperty = property.key.name.contains("sleep_window")
val isSnapchatPlusPurchaseDateProperty = property.key.name == "snapchat_plus_purchase_date"
if (isMessageListProperty) {
alertDialogs.MessageListPropertyDialog(property) { showDialog = false }
} else if (isSleepWindowProperty) {
alertDialogs.AutoOpenScheduleDialog(property as PropertyPair<String>) { showDialog = false }
} else if (isSnapchatPlusPurchaseDateProperty) {
alertDialogs.DatePickerPropertyDialog(property) { showDialog = false }
} else {
alertDialogs.KeyboardInputDialog(property) { showDialog = false }
}
@@ -801,6 +804,7 @@ class FeaturesRootSection : Routes.Route() {
)
} else {
val isMessageListProperty = property.key.name.endsWith("_messages")
val isSnapchatPlusPurchaseDateProperty = property.key.name == "snapchat_plus_purchase_date"
if (isMessageListProperty) {
val messageCount = try {
val messageList: List<String> = gson.fromJson(propertyValue.get().toString(), listTypeToken) ?: emptyList()
@@ -822,6 +826,16 @@ class FeaturesRootSection : Routes.Route() {
color = Color.White
)
}
} else if (isSnapchatPlusPurchaseDateProperty) {
Button(
onClick = click,
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.28f),
contentColor = Color.White
)
) {
Text(translation["button.set"] ?: "Set")
}
} else {
IconButton(onClick = click) {
Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null)

View File

@@ -75,6 +75,10 @@ import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.MapEventsOverlay
import org.osmdroid.views.overlay.Overlay
import java.io.File
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
import me.eternal.purrfectsnap.ui.util.Dialog as StandardDialog
@@ -512,6 +516,68 @@ class AlertDialogs(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DatePickerPropertyDialog(property: PropertyPair<*>, dismiss: () -> Unit = {}) {
val context = LocalContext.current
val zoneId = remember { ZoneId.systemDefault() }
val initialSelectedDateMillis = remember(property.value.get()) {
runCatching {
LocalDate
.parse(property.value.get().toString(), DateTimeFormatter.ISO_LOCAL_DATE)
.atStartOfDay(zoneId)
.toInstant()
.toEpochMilli()
}.getOrNull()
}
val datePickerState = rememberDatePickerState(initialSelectedDateMillis = initialSelectedDateMillis)
DefaultDialogCard {
DatePicker(
state = datePickerState,
showModeToggle = true
)
Row(
modifier = Modifier
.padding(top = 10.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End),
) {
Button(
onClick = { dismiss() },
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.08f),
contentColor = Color.White
)
) {
Text(text = translation["button.cancel"])
}
Button(
onClick = {
val selectedDate = datePickerState.selectedDateMillis?.let {
Instant.ofEpochMilli(it).atZone(zoneId).toLocalDate()
}
if (selectedDate == null) {
Toast.makeText(context, translation["invalid_input_toast"], Toast.LENGTH_SHORT).show()
return@Button
}
property.value.setAny(selectedDate.format(DateTimeFormatter.ISO_LOCAL_DATE))
dismiss()
},
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.32f),
contentColor = Color.White
)
) {
Text(text = translation["button.ok"])
}
}
}
}
@Composable
fun RawInputDialog(onDismiss: () -> Unit, onConfirm: (value: String) -> Unit) {
val focusRequester = remember { FocusRequester() }
@@ -1615,4 +1681,3 @@ class AlertDialogs(
}
}
}

View File

@@ -1240,6 +1240,10 @@
"name": "Settings Menu",
"description": "Choose between the new and legacy settings menu layouts"
},
"chat_hold_kill_actions": {
"name": "PurrfectSnap Chat Hold Kill",
"description": "Hold the chat/settings header button to kill selected app(s). Leave all options off to disable."
},
"spoof_snap_score": {
"name": "Spoof Snap Score",
"description": "Spoof your Snap Score (local only)",
@@ -1919,6 +1923,10 @@
"name": "Snapchat Plus",
"description": "Enables Snapchat Plus features\nSome Server-sided features may not work"
},
"snapchat_plus_purchase_date": {
"name": "Snapchat Plus Purchase Date",
"description": "Tap Save to choose a date from calendar (leave empty to use default)"
},
"media_upload_quality": {
"name": "Media Upload Quality",
"description": "Overrides the media upload quality",
@@ -2905,6 +2913,10 @@
"default": "Default",
"legacy": "Legacy"
},
"chat_hold_kill_actions": {
"kill_snapchat": "Kill Snapchat",
"kill_purrfectsnap": "Kill PurrfectSnap"
},
"path_format": {
"create_author_folder": "Create folder for each author",
"create_source_folder": "Create folder for each media source type",
@@ -3623,6 +3635,7 @@
"cancel": "Cancel",
"copy": "Copy",
"save": "Save",
"set": "Set",
"open": "Open",
"download": "Download",
"import": "Import",

View File

@@ -3,6 +3,8 @@ package me.eternal.purrfectsnap.common.config.impl
import me.eternal.purrfectsnap.common.config.ConfigContainer
import me.eternal.purrfectsnap.common.config.ConfigFlag
import me.eternal.purrfectsnap.common.config.FeatureNotice
import java.time.LocalDate
import java.time.format.DateTimeFormatter
class Global : ConfigContainer() {
companion object {
@@ -46,6 +48,12 @@ class Global : ConfigContainer() {
val betterLocation = container("better_location", BetterLocationConfig())
val snapchatPlus = unique("snapchat_plus", "not_subscribed", "basic", "ad_free") { requireRestart() }
val snapchatPlusPurchaseDate = string("snapchat_plus_purchase_date", "") {
requireRestart()
inputCheck = {
it.isBlank() || runCatching { LocalDate.parse(it, DateTimeFormatter.ISO_LOCAL_DATE) }.isSuccess
}
}
val mediaUploadQualityConfig = container("media_upload_quality", MediaUploadQualityConfig())
val performanceMode = container("performance_mode", PerformanceModeConfig()) { requireRestart() }.apply {
profile.set("max")

View File

@@ -63,6 +63,7 @@ class UserInterfaceTweaks : ConfigContainer() {
}
val preventForcedKeyboard = boolean("prevent_forced_keyboard") { requireRestart() }
val settingsMenu = unique("settings_menu", "default", "legacy") { requireRestart() }.apply { set("default") }
val chatHoldKillActions = multiple("chat_hold_kill_actions", "kill_snapchat", "kill_purrfectsnap") { requireRestart() }
inner class SpoofSnapScore : ConfigContainer(hasGlobalState = true) {
val customSnapScore = string("custom_snap_score") {

View File

@@ -8,9 +8,11 @@ import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.util.hook.hookConstructor
import me.eternal.purrfectsnap.mapper.impl.PlusSubscriptionMapper
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
class SnapchatPlus: Feature("SnapchatPlus") {
private val originalSubscriptionTime = (System.currentTimeMillis() - 7776000000L)
private val expirationTimeMillis = (System.currentTimeMillis() + 15552000000L)
override fun init() {
@@ -40,7 +42,24 @@ class SnapchatPlus: Feature("SnapchatPlus") {
//subscription status
set(statusField.getAsString()!!, 2)
set(originalSubscriptionTimeMillisField.getAsString()!!, originalSubscriptionTime)
val fallbackOriginalSubscriptionTime = System.currentTimeMillis() - 7776000000L
val customPurchaseDate = context.config.global.snapchatPlusPurchaseDate.get().trim()
val customPurchaseDateMillis = if (customPurchaseDate.isNotEmpty()) {
runCatching {
LocalDate
.parse(customPurchaseDate, DateTimeFormatter.ISO_LOCAL_DATE)
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
.toEpochMilli()
}.getOrNull()
} else {
null
}
set(
originalSubscriptionTimeMillisField.getAsString()!!,
customPurchaseDateMillis ?: fallbackOriginalSubscriptionTime
)
set(expirationTimeMillisField.getAsString()!!, expirationTimeMillis)
}
}

View File

@@ -6,6 +6,7 @@ import android.view.ViewGroup
import android.view.ViewGroup.MarginLayoutParams
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.TextView
import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent
import me.eternal.purrfectsnap.core.event.events.impl.BindViewEvent
import me.eternal.purrfectsnap.core.features.Feature
@@ -69,10 +70,157 @@ class UITweaks : Feature("UITweaks") {
}
}
private fun findSpotlightNavTarget(
event: AddViewEvent,
spotlightNavIds: Set<Int>,
spotlightNavNames: Set<String>
): View? {
data class ViewMetadata(
val view: View,
val resourceEntryName: String?,
val contentDescription: String?,
val text: String?,
val className: String
)
fun resourceEntryNameOrNull(view: View): String? {
val id = view.id
if (id == View.NO_ID || id == 0) return null
return runCatching { context.resources.getResourceEntryName(id) }.getOrNull()
}
val markerKeywords = setOf("spotlight", "following", "discover")
val viewChain = buildList {
var current: View? = event.view
repeat(5) {
current ?: return@repeat
add(
ViewMetadata(
view = current!!,
resourceEntryName = resourceEntryNameOrNull(current!!),
contentDescription = current!!.contentDescription?.toString(),
text = (current as? TextView)?.text?.toString(),
className = current!!.javaClass.name
)
)
current = current?.parent as? View
}
}
fun isExactMatch(metadata: ViewMetadata): Boolean {
return metadata.view.id in spotlightNavIds ||
metadata.resourceEntryName in spotlightNavNames
}
fun hasMarker(metadata: ViewMetadata): Boolean {
return listOfNotNull(
metadata.resourceEntryName,
metadata.contentDescription,
metadata.text
).any { value ->
markerKeywords.any { keyword ->
value.contains(keyword, ignoreCase = true)
}
}
}
fun isNavigationLike(metadata: ViewMetadata): Boolean {
val resourceEntryName = metadata.resourceEntryName.orEmpty()
val className = metadata.className
return resourceEntryName.contains("hova_nav", ignoreCase = true) ||
resourceEntryName.contains("bottom_nav", ignoreCase = true) ||
resourceEntryName.contains("nav", ignoreCase = true) ||
resourceEntryName.contains("tab", ignoreCase = true) ||
className.contains("navigation", ignoreCase = true) ||
className.contains("bottom", ignoreCase = true) ||
className.contains("tab", ignoreCase = true) ||
className.contains("hova", ignoreCase = true)
}
if (viewChain.none(::isExactMatch) && viewChain.none(::hasMarker)) {
return null
}
var sawSpotlightMarker = false
viewChain.forEach { metadata ->
if (isExactMatch(metadata) || hasMarker(metadata)) {
sawSpotlightMarker = true
}
if (sawSpotlightMarker && isNavigationLike(metadata)) {
return metadata.view
}
}
return viewChain.firstOrNull(::isExactMatch)?.view
}
private fun findSpotlightHeaderTabsTarget(view: View): View? {
fun collectTextLabels(current: View, depth: Int = 0, maxDepth: Int = 2): List<String> {
if (depth > maxDepth) return emptyList()
val ownText = listOfNotNull(
current.contentDescription?.toString(),
(current as? TextView)?.text?.toString()
).filter { it.isNotBlank() }
if (current !is ViewGroup) return ownText
return ownText + current.children().flatMap { child ->
collectTextLabels(child, depth + 1, maxDepth)
}
}
fun isHeaderMarkerText(value: String): Boolean {
return value.contains("spotlight", ignoreCase = true) ||
value.contains("discover", ignoreCase = true) ||
value.contains("following", ignoreCase = true)
}
val candidateChain = buildList {
var current: View? = view
repeat(6) {
current ?: return@repeat
add(current!!)
current = current?.parent as? View
}
}
candidateChain.forEach { candidate ->
val group = candidate as? ViewGroup ?: return@forEach
if (group.childCount !in 2..4) return@forEach
val directMarkedChildren = group.children().count { child ->
collectTextLabels(child).any(::isHeaderMarkerText)
}
if (directMarkedChildren < 2) return@forEach
val texts = collectTextLabels(group)
.map { it.trim() }
.filter { it.isNotBlank() }
.distinct()
val hasSpotlightOrDiscover = texts.any {
it.contains("spotlight", ignoreCase = true) ||
it.contains("discover", ignoreCase = true)
}
val hasFollowing = texts.any { it.contains("following", ignoreCase = true) }
if (hasSpotlightOrDiscover && hasFollowing) {
return group
}
}
return null
}
private fun onActivityCreate() {
val blockAds by context.config.global.blockAds
val hiddenElements by context.config.userInterface.hideUiComponents
val hideStorySuggestions by context.config.userInterface.hideStorySuggestions
val disableSpotlight by context.config.userInterface.disableSpotlight
val isImmersiveCamera by context.config.camera.immersiveCameraPreview
val displayMetrics = context.resources.displayMetrics
@@ -80,14 +228,36 @@ class UITweaks : Feature("UITweaks") {
val chatNoteRecordButton = getId("chat_note_record_button", "id")
val unreadHintButton = getId("unread_hint_button", "id")
val spotlightNavIds = listOf(
getId("hova_nav_spotlight", "id"),
getId("ngs_hova_nav_spotlight", "id"),
getId("hova_nav_spotlight_tab", "id"),
getId("hova_nav_spotlight_button", "id"),
getId("hova_nav_discover", "id"),
getId("ngs_hova_nav_discover", "id"),
getId("hova_nav_discover_tab", "id"),
getId("hova_nav_discover_button", "id")
).filter { it != 0 }.toSet()
val spotlightNavNames = setOf(
"hova_nav_spotlight",
"ngs_hova_nav_spotlight",
"hova_nav_spotlight_tab",
"hova_nav_spotlight_button",
"hova_nav_discover",
"ngs_hova_nav_discover",
"hova_nav_discover_tab",
"hova_nav_discover_button"
)
Resources::class.java.methods.first { it.name == "getDimensionPixelSize"}.hook(
Resources::class.java.methods.first { it.name == "getDimensionPixelSize" }.hook(
HookStage.AFTER,
{ isImmersiveCamera }
) { param ->
val id = param.arg<Int>(0)
if (id == getId("capri_viewfinder_default_corner_radius", "dimen") ||
id == getId("ngs_hova_nav_larger_camera_button_size", "dimen")) {
if (
id == getId("capri_viewfinder_default_corner_radius", "dimen") ||
id == getId("ngs_hova_nav_larger_camera_button_size", "dimen")
) {
param.setResult(0)
}
}
@@ -96,12 +266,17 @@ class UITweaks : Feature("UITweaks") {
if (event.view is FrameLayout) {
fun removeView() {
event.view.layoutParams = event.view.layoutParams?.apply {
width = 0; height = 0
width = 0
height = 0
} ?: return
}
val viewModelString = event.prevModel.toString()
val isMyStory by lazy { viewModelString.let { it.startsWith("StoryCarouselItemViewModel") && it.contains("storyId=") } }
val isMyStory by lazy {
viewModelString.let {
it.startsWith("StoryCarouselItemViewModel") && it.contains("storyId=")
}
}
if (hideStorySuggestions.contains("hide_my_stories") && isMyStory) {
removeView()
@@ -110,6 +285,10 @@ class UITweaks : Feature("UITweaks") {
}
}
context.event.subscribe(BindViewEvent::class, { disableSpotlight }) { event ->
findSpotlightHeaderTabsTarget(event.view)?.hideViewCompletely()
}
context.event.subscribe(AddViewEvent::class) { event ->
val viewId = event.view.id
val view = event.view
@@ -118,6 +297,15 @@ class UITweaks : Feature("UITweaks") {
hideStorySection(event)
}
findSpotlightNavTarget(event, spotlightNavIds, spotlightNavNames)?.takeIf { disableSpotlight }?.let { targetView ->
targetView.hideViewCompletely()
if (targetView !== view) {
view.hideViewCompletely()
}
event.canceled = true
return@subscribe
}
if (isImmersiveCamera) {
if (view.id == getId("edits_container", "id")) {
Hooker.hookObjectMethod(View::class.java, view, "layout", HookStage.BEFORE) {
@@ -134,7 +322,10 @@ class UITweaks : Feature("UITweaks") {
}
}
if (hiddenElements.contains("hide_billboard_prompt") && event.parent.javaClass.name.endsWith("BillboardFeedHeaderPromptComponent")) {
if (
hiddenElements.contains("hide_billboard_prompt") &&
event.parent.javaClass.name.endsWith("BillboardFeedHeaderPromptComponent")
) {
hideView(event.parent)
view.getValdiContext()?.componentContext?.get()?.dataBuilder {
val dismissFunction = get<Any>("_onDismiss") ?: return@subscribe
@@ -142,7 +333,11 @@ class UITweaks : Feature("UITweaks") {
}
}
if (event.parent.javaClass.name.endsWith("ConstraintLayout") && event.view is LinearLayout && hiddenElements.contains("hide_map_reactions")) {
if (
event.parent.javaClass.name.endsWith("ConstraintLayout") &&
event.view is LinearLayout &&
hiddenElements.contains("hide_map_reactions")
) {
val viewGroup = event.view as ViewGroup
val children = viewGroup.children()
@@ -154,7 +349,10 @@ class UITweaks : Feature("UITweaks") {
}
}
if (event.parent.javaClass.name.endsWith("PreviewBottomToolbarView") && hiddenElements.contains("hide_post_to_story_buttons")) {
if (
event.parent.javaClass.name.endsWith("PreviewBottomToolbarView") &&
hiddenElements.contains("hide_post_to_story_buttons")
) {
if (event.parent.childCount == 1) {
event.view.hideViewCompletely()
}
@@ -163,7 +361,8 @@ class UITweaks : Feature("UITweaks") {
if (viewId == getId("send_btn", "id") && hiddenElements.contains("hide_post_to_story_buttons")) {
// hide previous view
if (event.parent.childCount > 0) {
val lastChild = event.parent.getChildAt(event.parent.childCount - 1)?.takeIf { it is LinearLayout } ?: return@subscribe
val lastChild = event.parent.getChildAt(event.parent.childCount - 1)
?.takeIf { it is LinearLayout } ?: return@subscribe
context.log.verbose("Hiding post to story button")
lastChild.hideViewCompletely()
}
@@ -174,7 +373,11 @@ class UITweaks : Feature("UITweaks") {
if (hiddenElements.contains("hide_live_location_share_button")) {
chatInputBar?.onLayoutChange {
chatInputBar!!.children().lastOrNull { it.javaClass.name.endsWith("AppCompatImageButton") && runCatching { it.resources.getResourceName(it.id) }.getOrNull() == null }
chatInputBar!!.children()
.lastOrNull {
it.javaClass.name.endsWith("AppCompatImageButton") &&
runCatching { it.resources.getResourceName(it.id) }.getOrNull() == null
}
?.hideViewCompletely()
}
}

View File

@@ -1,14 +1,18 @@
package me.eternal.purrfectsnap.core.ui.menu.impl
import android.app.ActivityManager
import android.os.Process
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageView
import me.eternal.purrfectsnap.common.Constants
import me.eternal.purrfectsnap.common.ui.OverlayType
import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent
import me.eternal.purrfectsnap.core.ui.menu.AbstractMenu
import me.eternal.purrfectsnap.core.util.ktx.getDrawable
import me.eternal.purrfectsnap.core.util.ktx.getStyledAttributes
import me.eternal.purrfectsnap.core.util.ktx.vibrateLongPress
class SettingsGearInjector : AbstractMenu() {
private val hovaHeaderAddFriendIconId by lazy {
@@ -47,6 +51,9 @@ class SettingsGearInjector : AbstractMenu() {
this@SettingsGearInjector.context.log.info("Gear icon clicked.", logTag)
this@SettingsGearInjector.context.bridgeClient.openOverlay(OverlayType.SETTINGS)
}
setOnLongClickListener {
this@SettingsGearInjector.handleChatHoldKillAction()
}
}
val layoutParams = FrameLayout.LayoutParams(
@@ -88,4 +95,25 @@ class SettingsGearInjector : AbstractMenu() {
}
}
}
private fun handleChatHoldKillAction(): Boolean {
val selectedActions = context.config.userInterface.chatHoldKillActions.get()
if (selectedActions.isEmpty()) return false
context.androidContext.vibrateLongPress()
context.mainActivity?.vibrateLongPress()
if (selectedActions.contains("kill_purrfectsnap")) {
runCatching {
val activityManager = context.androidContext.getSystemService(ActivityManager::class.java)
activityManager?.killBackgroundProcesses(Constants.MODULE_PACKAGE_NAME)
}
}
if (selectedActions.contains("kill_snapchat")) {
Process.killProcess(Process.myPid())
}
return true
}
}

View File

@@ -1,12 +1,16 @@
package me.eternal.purrfectsnap.core.ui.menu.impl
import android.app.ActivityManager
import android.os.Process
import android.view.View
import android.widget.FrameLayout
import me.eternal.purrfectsnap.common.Constants
import me.eternal.purrfectsnap.common.ui.OverlayType
import me.eternal.purrfectsnap.core.ui.menu.AbstractMenu
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.util.ktx.getId
import me.eternal.purrfectsnap.core.util.ktx.vibrateLongPress
class SettingsMenu : AbstractMenu() {
private val hovaHeaderSearchIconId by lazy {
@@ -22,8 +26,32 @@ class SettingsMenu : AbstractMenu() {
view.setOnClickListener {
context.bridgeClient.openOverlay(OverlayType.SETTINGS)
}
view.setOnLongClickListener {
handleChatHoldKillAction()
}
}
}
}
}
private fun handleChatHoldKillAction(): Boolean {
val selectedActions = context.config.userInterface.chatHoldKillActions.get()
if (selectedActions.isEmpty()) return false
context.androidContext.vibrateLongPress()
context.mainActivity?.vibrateLongPress()
if (selectedActions.contains("kill_purrfectsnap")) {
runCatching {
val activityManager = context.androidContext.getSystemService(ActivityManager::class.java)
activityManager?.killBackgroundProcesses(Constants.MODULE_PACKAGE_NAME)
}
}
if (selectedActions.contains("kill_snapchat")) {
Process.killProcess(Process.myPid())
}
return true
}
}

View File

@@ -338,4 +338,14 @@ case "$1" in
esac
cd "$RUST_DIR"
# macOS CI runners may inject DYLD override variables that break Rust/cargo
# processes with libc++abi symbol shim errors and bus error 10.
if [[ "$HOST_TAG" == darwin-* ]]; then
unset DYLD_INSERT_LIBRARIES
unset DYLD_LIBRARY_PATH
unset DYLD_FRAMEWORK_PATH
unset DYLD_ROOT_PATH
fi
rustup run "$TOOLCHAIN" cargo build --release --target "$1"