From 95f98221e3b67c05d7ae012812f1f271a14f406e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=CE=9ET=CE=9ERNAL?= Date: Thu, 26 Mar 2026 12:13:52 +0530 Subject: [PATCH] v1.5.4 --- .../pages/location/BetterLocationRoot.kt | 2 +- .../pages/themes/aphelion/AphelionHomeView.kt | 62 ++++++++++++++++++- .../pages/themes/legacy/LegacyTheme.kt | 44 +++++++++++++ build.gradle.kts | 4 +- changelogs-stable.txt | 5 ++ .../core/action/impl/BulkMessagingAction.kt | 40 ++++++++++-- gradle.properties | 4 +- 7 files changed, 151 insertions(+), 10 deletions(-) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/location/BetterLocationRoot.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/location/BetterLocationRoot.kt index 500407fd..e2c16a69 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/location/BetterLocationRoot.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/location/BetterLocationRoot.kt @@ -126,7 +126,7 @@ class BetterLocationRoot : Routes.Route() { overflow = TextOverflow.Ellipsis ) Text( - text = context.translation.format( + text = translation.format( "spoofed_coordinates_title", "latitude" to friendLocation.latitude.toFloat().toString(), "longitude" to friendLocation.longitude.toFloat().toString() diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionHomeView.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionHomeView.kt index 3891b9d9..590e1732 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionHomeView.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionHomeView.kt @@ -466,6 +466,10 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) { var changelogLoading by remember { mutableStateOf(false) } var changelogError by remember { mutableStateOf(null) } var changelogVersion by remember { mutableStateOf(null) } + var showFullChangelogDialog by rememberSaveable { mutableStateOf(false) } + var fullChangelogText by rememberSaveable { mutableStateOf(null) } + var fullChangelogLoading by remember { mutableStateOf(false) } + var fullChangelogError by remember { mutableStateOf(null) } var showAnnouncementsDialog by rememberSaveable { mutableStateOf(false) } var announcementsText by rememberSaveable { mutableStateOf(null) } var announcementsLoading by remember { mutableStateOf(false) } @@ -531,6 +535,31 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) { } } + fun loadFullChangelog() { + if (fullChangelogText != null) return + fullChangelogLoading = true + fullChangelogError = null + coroutineScope.launch(Dispatchers.IO) { + val url = if (updateChannel == "prerelease") changelogPrereleaseUrl else changelogStableUrl + runCatching { + OkHttpClient().newCall(Request.Builder().url(url).build()).execute().use { response -> + val body = response.body?.string() ?: throw IllegalStateException("Empty body") + body.trim() + } + }.onSuccess { text -> + withContext(Dispatchers.Main) { + fullChangelogText = text + fullChangelogLoading = false + } + }.onFailure { e -> + withContext(Dispatchers.Main) { + fullChangelogError = e.message ?: "Failed to fetch" + fullChangelogLoading = false + } + } + } + } + val borderPath = remember { Path() } val uPath = remember { Path() } @@ -626,7 +655,8 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) { val announcementShift by remember(focusFactor) { derivedStateOf { (-6 * focusFactor).dp } } Row( modifier = Modifier.align(Alignment.CenterStart).graphicsLayer { translationX = announcementShift.toPx() }, - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) ) { AphelionTopBarActionChip( icon = Icons.Filled.Notifications, label = null, @@ -634,6 +664,12 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) { contentDescription = translation["announcements_button_description"], haptic = haptic ) { showAnnouncementsDialog = true; loadAnnouncements() } + AphelionTopBarActionChip( + icon = Icons.Filled.Description, label = null, + shrinkFactor = (1f - focusFactor).coerceIn(0f, 1f), + contentDescription = translation.getOrNull("changelog_button_description") ?: "Open full changelog", + haptic = haptic + ) { showFullChangelogDialog = true; loadFullChangelog() } } val settingsShift by remember(focusFactor) { derivedStateOf { (6 * focusFactor).dp } } Row( @@ -778,6 +814,7 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) { text = "", icon = Icons.Filled.Notifications, confirmButtonText = translation["announcements_dialog_close_button"] ?: "Close", onConfirm = { showAnnouncementsDialog = false }, + showCloseButton = false, customContent = { Column(modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) { if (announcementsLoading) CircularProgressIndicator(color = Color.White) @@ -796,6 +833,7 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) { onConfirm = { haptic.performHapticFeedback(HapticFeedbackType.LongPress); showChangelogDialog = false; handleUpdateAction() }, dismissButtonText = translation["changelog_dialog_cancel_button"] ?: "Cancel", onDismiss = { showChangelogDialog = false }, + showCloseButton = false, customContent = { Column(modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) { if (changelogLoading) CircularProgressIndicator(color = Color.White) @@ -806,6 +844,28 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) { ) } + if (showFullChangelogDialog) { + AestheticDialog( + onDismissRequest = { showFullChangelogDialog = false }, + title = translation["changelog_dialog_title"] ?: "Changelog", + text = "", + icon = Icons.Filled.Description, + confirmButtonText = translation["announcements_dialog_close_button"] ?: "Close", + onConfirm = { showFullChangelogDialog = false }, + showCloseButton = false, + customContent = { + Column( + modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + if (fullChangelogLoading) CircularProgressIndicator(color = Color.White) + else if (fullChangelogError != null) Text(fullChangelogError!!, color = Color.Red, fontSize = 14.sp) + else Text(fullChangelogText ?: translation["changelog_dialog_empty"] ?: "", color = PurrfectPalette.textPrimary, fontSize = 14.sp) + } + } + ) + } + if (showQuickActionsMenu) { QuickActionsDialog( quickActions = cards, diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt index e7e95dce..eb5eaff5 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt @@ -345,6 +345,10 @@ object LegacyTheme : ThemeContract { var changelogError by remember { mutableStateOf(null) } var changelogText by remember { mutableStateOf(null) } var changelogVersion by remember { mutableStateOf(null) } + var showFullChangelogDialog by remember { mutableStateOf(false) } + var fullChangelogLoading by remember { mutableStateOf(false) } + var fullChangelogError by remember { mutableStateOf(null) } + var fullChangelogText by remember { mutableStateOf(null) } var showAnnouncementsDialog by remember { mutableStateOf(false) } var announcementsLoading by remember { mutableStateOf(false) } var announcementsError by remember { mutableStateOf(null) } @@ -415,6 +419,23 @@ object LegacyTheme : ThemeContract { } } + fun loadFullChangelog(url: String) { + if (fullChangelogText != null) return + fullChangelogLoading = true; fullChangelogError = null + coroutineScope.launch(Dispatchers.IO) { + runCatching { + changelogClient.newCall(Request.Builder().url(url).build()).execute().use { response -> + if (!response.isSuccessful) throw IllegalStateException("Failed to fetch changelog (${response.code})") + response.body?.string()?.trim() ?: throw IllegalStateException("Empty changelog body") + } + }.onSuccess { text -> + withContext(Dispatchers.Main) { fullChangelogText = text; fullChangelogLoading = false } + }.onFailure { error -> + withContext(Dispatchers.Main) { fullChangelogError = error.message ?: "Failed to load changelog"; fullChangelogLoading = false } + } + } + } + LaunchedEffect(Unit) { if (context.sharedPreferences.getBoolean("show_changelog_on_launch", false)) { val version = context.sharedPreferences.getString("changelog_version_on_launch", null) @@ -450,6 +471,9 @@ object LegacyTheme : ThemeContract { LocalTopBarActionChip(icon = Icons.Filled.Notifications, label = null, contentDescription = translation["announcements_button_description"]) { showAnnouncementsDialog = true; loadAnnouncements() } + LocalTopBarActionChip(icon = Icons.Filled.Description, label = null, contentDescription = translation.getOrNull("changelog_button_description") ?: "Open full changelog") { + showFullChangelogDialog = true; loadFullChangelog(changelogUrl) + } } Row(modifier = Modifier.wrapContentWidth(Alignment.End), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { LocalHomeActionChips() @@ -570,6 +594,7 @@ object LegacyTheme : ThemeContract { onConfirm = { showChangelogDialog = false; handleUpdateAction() }, dismissButtonText = translation["changelog_dialog_cancel_button"] ?: "Cancel", onDismiss = { showChangelogDialog = false }, + showCloseButton = false, customContent = { Column(modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) { if (changelogLoading) CircularProgressIndicator(color = Color.White) @@ -587,6 +612,7 @@ object LegacyTheme : ThemeContract { text = "", icon = Icons.Filled.Notifications, confirmButtonText = translation["announcements_dialog_close_button"] ?: "Close", onConfirm = { showAnnouncementsDialog = false }, + showCloseButton = false, customContent = { Column(modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) { if (announcementsLoading) CircularProgressIndicator(color = Color.White) @@ -597,6 +623,24 @@ object LegacyTheme : ThemeContract { ) } + if (showFullChangelogDialog) { + AestheticDialog( + onDismissRequest = { showFullChangelogDialog = false }, + title = translation["changelog_dialog_title"] ?: "Changelog", + text = "", icon = Icons.Filled.Description, + confirmButtonText = translation["announcements_dialog_close_button"] ?: "Close", + onConfirm = { showFullChangelogDialog = false }, + showCloseButton = false, + customContent = { + Column(modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) { + if (fullChangelogLoading) CircularProgressIndicator(color = Color.White) + else if (fullChangelogError != null) Text(fullChangelogError!!, color = Color.Red, fontSize = 14.sp) + else Text(fullChangelogText ?: translation["changelog_dialog_empty"] ?: "", color = PurrfectPalette.textPrimary, fontSize = 14.sp) + } + } + ) + } + if (showQuickActionsMenu) { QuickActionsDialog( quickActions = cards, diff --git a/build.gradle.kts b/build.gradle.kts index d245d176..0bdb297c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -33,8 +33,8 @@ tasks.register("getVersion") { } // You can still set these for legacy use by submodules or scripts: -rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.5.3").get()) -rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("298").get().toInt()) +rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.5.4").get()) +rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("300").get().toInt()) rootProject.ext.set("applicationId", "me.eternal.purrfectsnap") // buildHash: when PurrfectSnap or Snapchat is updated, mappings become outdated and auto-regenerate. // Include version code so each release has a different hash; use random for uniqueness within same version. diff --git a/changelogs-stable.txt b/changelogs-stable.txt index fe1b0574..cbe5a802 100644 --- a/changelogs-stable.txt +++ b/changelogs-stable.txt @@ -1,3 +1,8 @@ +## v1.5.4 +- Fix: Streak & Non-Streak category in Bulk Messaging Action for newer versions of snap +- Fix: Spoof Coordinates Title +- New: Changelogs feature + ## v1.5.3 - New: Mark as Seen Mode(Limit per run[Custom] or Complete Queue) - Fix: PurrfectSnap crash if you try to open any dialog setting through in-app overlay diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/action/impl/BulkMessagingAction.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/action/impl/BulkMessagingAction.kt index bca7ff38..4d75f884 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/action/impl/BulkMessagingAction.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/action/impl/BulkMessagingAction.kt @@ -113,6 +113,18 @@ class BulkMessagingAction : AbstractAction() { private val translation by lazy { context.translation.getCategory("bulk_messaging_action") } private val betterLocation by lazy { context.feature(BetterLocation::class) } + private fun hasReliableStreak(friend: FriendInfo, streakFeedUserIds: Set): Boolean { + val userId = friend.userId ?: return false + if (userId in streakFeedUserIds) return true + if (friend.streakExpirationTimestamp > 0L) return true + if (friend.streakLength > 0) return true + val categories = friend.friendmojiCategories?.split(",") ?: return false + return categories.any { category -> + category.contains("streak", ignoreCase = true) || + category.contains("hourglass", ignoreCase = true) + } + } + private object BulkMessagingPalette { val background = Brush.verticalGradient( listOf( @@ -286,7 +298,12 @@ class BulkMessagingAction : AbstractAction() { } } - private fun filterFriends(friends: List, filter: Filter, nameFilter: String): List { + private fun filterFriends( + friends: List, + filter: Filter, + nameFilter: String, + streakFeedUserIds: Set = emptySet() + ): List { val userIdBlacklist = arrayOf( context.database.myUserId, "b42f1f70-5a8b-4c53-8c25-34e7ec9e6781", // myai @@ -310,8 +327,12 @@ class BulkMessagingAction : AbstractAction() { Filter.SUGGESTED -> friend.friendLinkType == FriendLinkType.SUGGESTED.value Filter.DELETED -> friend.friendLinkType == FriendLinkType.DELETED.value Filter.BUSINESS_ACCOUNTS -> friend.businessCategory > 0 - Filter.STREAKS -> friend.friendLinkType == FriendLinkType.MUTUAL.value && friend.addedTimestamp > 0 && friend.streakLength != 0 - Filter.NON_STREAKS -> friend.friendLinkType == FriendLinkType.MUTUAL.value&& friend.addedTimestamp > 0 && friend.streakLength == 0 + Filter.STREAKS -> friend.friendLinkType == FriendLinkType.MUTUAL.value && + friend.addedTimestamp > 0 && + hasReliableStreak(friend, streakFeedUserIds) + Filter.NON_STREAKS -> friend.friendLinkType == FriendLinkType.MUTUAL.value && + friend.addedTimestamp > 0 && + !hasReliableStreak(friend, streakFeedUserIds) Filter.FOLLOWING -> { val isFollowing = friend.friendLinkType == FriendLinkType.FOLLOWING.value || (friend.friendLinkType == FriendLinkType.OUTGOING.value && @@ -390,10 +411,21 @@ class BulkMessagingAction : AbstractAction() { val incomingRequestUserIds = if (filter == Filter.INCOMING || filter == Filter.INCOMING_FOLLOWER) { runCatching { context.database.getIncomingRequestUserIds() }.getOrElse { emptySet() } } else emptySet() + val streakFeedUserIds = if (filter == Filter.STREAKS || filter == Filter.NON_STREAKS) { + runCatching { + context.database.getFeedEntries(Int.MAX_VALUE) + .filter { it.conversationType == 0 && it.participantsSize == 2 } + .filter { (it.streakCount ?: 0) > 0 || (it.streakExpirationTimestampMs ?: 0L) > 0L } + .mapNotNull { entry -> + entry.friendUserId ?: entry.participants?.firstOrNull { id -> id != context.database.myUserId } + } + .toSet() + }.getOrElse { emptySet() } + } else emptySet() val newFriends = if (conversationType == ConversationType.FRIENDS_ONLY || conversationType == ConversationType.BOTH) { context.database.getAllFriends().let { friends -> - filterFriends(friends, filter, nameFilter) + filterFriends(friends, filter, nameFilter, streakFeedUserIds) } .filter { it.userId?.let { id -> !hiddenFriendIds.contains(id) } == true } .filter { friend -> diff --git a/gradle.properties b/gradle.properties index 95135ae0..2f0749fc 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,8 +7,8 @@ org.gradle.configuration-cache=true org.gradle.configuration-cache.problems=warn nativeAbis=arm64-v8a -APP_VERSION_NAME=1.5.3 -APP_VERSION_CODE=298 +APP_VERSION_NAME=1.5.4 +APP_VERSION_CODE=300 debug_build_hash=18fe2a814d0e2eb5 psIntegrityPinnedSha256= EXPECTED_CERT_SHA256=0f188cb17d8ea4902ee15ce98f4928ba4226a4790fa3c52f62d776b08e46ca1c