From 2913cfe794757a8d86fe82c569ec933309694f4e Mon Sep 17 00:00:00 2001 From: imCrest <217463890+imCrest@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:12:25 +0000 Subject: [PATCH] Merge pull request #4 from imCrest/imCrest/add-customizable-purchase-date-feature-di5qqb Add Snapchat Plus purchase date setting and chat-hold kill actions (UI + core) --- .../pages/features/FeaturesRootSection.kt | 14 ++++ .../purrfectsnap/ui/util/AlertDialogs.kt | 67 ++++++++++++++++++- common/src/main/assets/lang/en_US.json | 13 ++++ .../purrfectsnap/common/config/impl/Global.kt | 8 +++ .../common/config/impl/UserInterfaceTweaks.kt | 1 + .../core/features/impl/global/SnapchatPlus.kt | 23 ++++++- .../core/ui/menu/impl/SettingsGearInjector.kt | 26 +++++++ .../core/ui/menu/impl/SettingsMenu.kt | 26 +++++++ 8 files changed, 175 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt index 764580e8..e0d9dfa9 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt @@ -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) { 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 = 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) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/AlertDialogs.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/AlertDialogs.kt index d386e1fa..b46892e9 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/AlertDialogs.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/AlertDialogs.kt @@ -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( } } } - diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 07bd00cc..7369cf61 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -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", diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Global.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Global.kt index d7f3f223..d2d24343 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Global.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Global.kt @@ -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") diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/UserInterfaceTweaks.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/UserInterfaceTweaks.kt index c0bcdd0a..a07ec875 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/UserInterfaceTweaks.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/UserInterfaceTweaks.kt @@ -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") { diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/SnapchatPlus.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/SnapchatPlus.kt index 048e9ad1..aa5fbb98 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/SnapchatPlus.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/SnapchatPlus.kt @@ -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) } } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/SettingsGearInjector.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/SettingsGearInjector.kt index 09240a3c..c51e1d83 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/SettingsGearInjector.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/SettingsGearInjector.kt @@ -1,9 +1,12 @@ 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 @@ -47,6 +50,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 +94,24 @@ class SettingsGearInjector : AbstractMenu() { } } } + + private fun handleChatHoldKillAction(): Boolean { + val selectedActions = context.config.userInterface.chatHoldKillActions.get() + if (selectedActions.isEmpty()) return false + + 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 + } } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/SettingsMenu.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/SettingsMenu.kt index f9052bf0..312e638f 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/SettingsMenu.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/SettingsMenu.kt @@ -1,7 +1,10 @@ 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 @@ -22,8 +25,31 @@ 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.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 + } }