1 Commits

Author SHA1 Message Date
ΞTΞRNAL
95f98221e3 v1.5.4 2026-03-26 12:13:52 +05:30
7 changed files with 151 additions and 10 deletions

View File

@@ -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()

View File

@@ -466,6 +466,10 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
var changelogLoading by remember { mutableStateOf(false) }
var changelogError by remember { mutableStateOf<String?>(null) }
var changelogVersion by remember { mutableStateOf<String?>(null) }
var showFullChangelogDialog by rememberSaveable { mutableStateOf(false) }
var fullChangelogText by rememberSaveable { mutableStateOf<String?>(null) }
var fullChangelogLoading by remember { mutableStateOf(false) }
var fullChangelogError by remember { mutableStateOf<String?>(null) }
var showAnnouncementsDialog by rememberSaveable { mutableStateOf(false) }
var announcementsText by rememberSaveable { mutableStateOf<String?>(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,

View File

@@ -345,6 +345,10 @@ object LegacyTheme : ThemeContract {
var changelogError by remember { mutableStateOf<String?>(null) }
var changelogText by remember { mutableStateOf<String?>(null) }
var changelogVersion by remember { mutableStateOf<String?>(null) }
var showFullChangelogDialog by remember { mutableStateOf(false) }
var fullChangelogLoading by remember { mutableStateOf(false) }
var fullChangelogError by remember { mutableStateOf<String?>(null) }
var fullChangelogText by remember { mutableStateOf<String?>(null) }
var showAnnouncementsDialog by remember { mutableStateOf(false) }
var announcementsLoading by remember { mutableStateOf(false) }
var announcementsError by remember { mutableStateOf<String?>(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,

View File

@@ -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.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.

View File

@@ -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

View File

@@ -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<String>): 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<FriendInfo>, filter: Filter, nameFilter: String): List<FriendInfo> {
private fun filterFriends(
friends: List<FriendInfo>,
filter: Filter,
nameFilter: String,
streakFeedUserIds: Set<String> = emptySet()
): List<FriendInfo> {
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 ->

View File

@@ -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