4 Commits

Author SHA1 Message Date
ΞTΞRNAL
95f98221e3 v1.5.4 2026-03-26 12:13:52 +05:30
ΞTΞRNAL
dbbc52b67f v1.5.3 2026-03-25 21:59:31 +05:30
ΞTΞRNAL
b7c8042a93 Merge branch 'dev' of https://github.com/particle-box/PurrfectSnap into dev 2026-03-24 03:37:30 +05:30
ΞTΞRNAL
141b5e16a0 feat: continuous snap feature! 2026-03-24 03:36:39 +05:30
19 changed files with 804 additions and 99 deletions

View File

@@ -53,8 +53,6 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.zIndex
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
@@ -78,6 +76,8 @@ import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.*
import me.eternal.purrfectsnap.ui.util.Dialog
import me.eternal.purrfectsnap.ui.util.DialogProperties
import org.json.JSONArray
import org.json.JSONObject
import kotlin.math.max

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

@@ -40,7 +40,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.eternal.purrfectsnap.common.config.ConfigFlag
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog as StandardDialog
import androidx.core.net.toUri
import com.github.skydoves.colorpicker.compose.*
import com.google.gson.JsonParser
@@ -67,6 +66,7 @@ import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Overlay
import java.io.File
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
import me.eternal.purrfectsnap.ui.util.Dialog as StandardDialog
class AlertDialogs(
@@ -1093,7 +1093,7 @@ class AlertDialogs(
val lat = remember { mutableStateOf(coordinates.first.toString()) }
val lon = remember { mutableStateOf(coordinates.second.toString()) }
Dialog(
StandardDialog(
onDismissRequest = {
customCoordinatesDialog = false
},
@@ -1398,7 +1398,7 @@ class AlertDialogs(
// Add/Edit message dialog
if (showAddDialog) {
Dialog(
StandardDialog(
onDismissRequest = { showAddDialog = false },
properties = DialogProperties(
usePlatformDefaultWidth = false

View File

@@ -4,12 +4,28 @@ package me.eternal.purrfectsnap.ui.util
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.graphics.Outline
import android.os.Build
import android.provider.Settings
import android.view.*
import android.view.View.OnAttachStateChangeListener
import androidx.activity.ComponentDialog
import androidx.activity.addCallback
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.ExperimentalComposeUiApi
@@ -19,9 +35,12 @@ import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.*
import androidx.compose.ui.semantics.dialog
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.compose.ui.window.SecureFlagPolicy
import androidx.core.view.WindowCompat
import androidx.lifecycle.findViewTreeLifecycleOwner
@@ -33,6 +52,12 @@ import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import java.util.UUID
import kotlin.math.roundToInt
private tailrec fun Context.findActivity(): Activity? = when (this) {
is Activity -> this
is ContextWrapper -> baseContext?.findActivity()
else -> null
}
class DialogProperties constructor(
val dismissOnBackPress: Boolean = true,
val dismissOnClickOutside: Boolean = true,
@@ -83,6 +108,17 @@ fun Dialog(
content: @Composable () -> Unit
) {
val view = LocalView.current
var forceInline by remember(view) { mutableStateOf(false) }
val hostActivity = remember(view) { view.context.findActivity() }
val shouldUseInline = forceInline || hostActivity == null || hostActivity.isFinishing || hostActivity.isDestroyed
if (shouldUseInline) {
InlineDialog(
onDismissRequest = onDismissRequest,
dismissOnClickOutside = properties.dismissOnClickOutside,
content = content
)
return
}
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
val composition = rememberCompositionContext()
@@ -110,11 +146,30 @@ fun Dialog(
}
DisposableEffect(dialog) {
// Set the dialog's window type to TYPE_APPLICATION_OVERLAY so it's compatible with compose overlays
if (Settings.canDrawOverlays(view.context) && view.context !is Activity) {
dialog.window?.setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY)
val showDialog = {
try {
dialog.prepareWindowForHost()
if (!dialog.isShowing) {
dialog.show()
}
} catch (_: WindowManager.BadTokenException) {
forceInline = true
}
}
if (view.isAttachedToWindow || view.windowToken != null || view.rootView?.windowToken != null) {
showDialog()
} else {
val listener = object : OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) {
v.removeOnAttachStateChangeListener(this)
showDialog()
}
override fun onViewDetachedFromWindow(v: View) = Unit
}
view.addOnAttachStateChangeListener(listener)
}
dialog.show()
onDispose {
dialog.dismiss()
@@ -131,6 +186,75 @@ fun Dialog(
}
}
@Composable
private fun InlineDialog(
onDismissRequest: () -> Unit,
dismissOnClickOutside: Boolean,
content: @Composable () -> Unit
) {
val density = LocalDensity.current
val displayMetrics = LocalContext.current.resources.displayMetrics
val screenWidthDp = with(density) { displayMetrics.widthPixels.toDp() }
val screenHeightDp = with(density) { displayMetrics.heightPixels.toDp() }
val interactionSource = remember { MutableInteractionSource() }
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
visible = true
}
Popup(
alignment = androidx.compose.ui.Alignment.Center,
properties = PopupProperties(
focusable = true,
dismissOnBackPress = true,
dismissOnClickOutside = dismissOnClickOutside
),
onDismissRequest = onDismissRequest
) {
Box(
modifier = Modifier
.width(screenWidthDp)
.height(screenHeightDp)
.then(
if (dismissOnClickOutside) {
Modifier.clickable(
interactionSource = interactionSource,
indication = null,
onClick = onDismissRequest
)
} else {
Modifier
}
)
.semantics { dialog() },
contentAlignment = androidx.compose.ui.Alignment.Center
) {
AnimatedVisibility(
visible = visible,
enter = fadeIn(animationSpec = tween(180)) + scaleIn(
initialScale = 0.92f,
animationSpec = spring(dampingRatio = 0.82f, stiffness = 520f)
),
exit = fadeOut(animationSpec = tween(120)) + scaleOut(
targetScale = 0.96f,
animationSpec = tween(120)
)
) {
Box(
modifier = Modifier.clickable(
interactionSource = interactionSource,
indication = null,
onClick = {}
)
) {
content()
}
}
}
}
}
interface DialogWindowProvider {
val window: Window
}
@@ -279,6 +403,26 @@ private class DialogWrapper(
}
}
fun prepareWindowForHost() {
val hostActivity = composeView.context.findActivity()
val hostToken = composeView.applicationWindowToken
?: composeView.windowToken
?: composeView.rootView?.applicationWindowToken
?: composeView.rootView?.windowToken
if (hostActivity == null) {
when {
hostToken != null -> window?.setType(WindowManager.LayoutParams.TYPE_APPLICATION_ATTACHED_DIALOG)
Settings.canDrawOverlays(composeView.context) -> window?.setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY)
}
}
if (hostToken != null) {
window?.attributes = window?.attributes?.apply {
token = hostToken
}
}
hostActivity?.let { setOwnerActivity(it) }
}
private fun setLayoutDirection(layoutDirection: LayoutDirection) {
dialogLayout.layoutDirection = when (layoutDirection) {
LayoutDirection.Ltr -> android.util.LayoutDirection.LTR

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.1").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("294").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,18 @@
## 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
- New: Translucent Dialogs & refreshed animation
- Fix: Adjusted Snap Preview Location
## v1.5.2
- New: Continuous snap sender feature!
- Fix: Snap send failure for E2E Chats
## v1.5.1
- Fix: Splitting issue for video snaps sent through gallery media send override!
- New: Toggle to turn off/on splitting for video snaps sent through send override

View File

@@ -1190,6 +1190,14 @@
"name": "Mark Snap as Seen Button",
"description": "Adds a button to mark a Snap as seen when viewing it.\nThis will work even when Stealth Mode is enabled"
},
"mark_snap_as_seen_processing_mode": {
"name": "Mark Snaps as Seen Mode",
"description": "Choose whether to process a limited number of snaps per run or the entire queue at once"
},
"mark_snap_as_seen_limit": {
"name": "Mark Snaps as Seen Limit",
"description": "How many snaps to process per run when the mode is set to limit"
},
"skip_when_marking_as_seen": {
"name": "Skip When Marking as Seen",
"description": "Automatically skips to the next Snap when marking a Snap as seen.\nUse in combination with Mark Snap as Seen Button"
@@ -3639,4 +3647,4 @@
"openai": "OpenAI",
"openrouter": "OpenRouter"
}
}
}

View File

@@ -1254,6 +1254,14 @@
"name": "Mark Snap as Seen Button",
"description": "Adds a button to mark a Snap as seen when viewing it.\nThis will work even when Stealth Mode is enabled"
},
"mark_snap_as_seen_processing_mode": {
"name": "Mark Snaps as Seen Mode",
"description": "Choose whether to process a limited number of snaps per run or the entire queue at once"
},
"mark_snap_as_seen_limit": {
"name": "Mark Snaps as Seen Limit",
"description": "How many snaps to process per run when the mode is set to limit"
},
"skip_when_marking_as_seen": {
"name": "Skip When Marking as Seen",
"description": "Automatically skips to the next Snap when marking a Snap as seen.\nUse in combination with Mark Snap as Seen Button"
@@ -2514,6 +2522,10 @@
"remove_audio_note_duration": "Remove Audio Note Duration",
"remove_audio_note_transcript_capability": "Remove Audio Note Transcript Capability"
},
"mark_snap_as_seen_processing_mode": {
"limit": "Limit Per Run",
"complete": "Complete Queue"
},
"hide_ui_components": {
"hide_profile_call_buttons": "Remove Profile Call Buttons",
"hide_chat_call_buttons": "Remove Chat Call Buttons",
@@ -3328,6 +3340,12 @@
"duration": "Duration: {duration}",
"saveable_snap_hint": "Make Snap saveable in the chat",
"single_send_hint": "Send as one snap",
"continuous_send_toggle": "Continuous snap sender",
"continuous_send_count_label": "Send count",
"continuous_send_count_placeholder": "Enter number of sends",
"continuous_send_hint": "This will send the same snap to the same recipient multiple times.",
"continuous_send_invalid_count": "Enter a valid send count greater than 0",
"continuous_send_single_send_conflict": "Continuous sending is not available while 'Send as one snap' is enabled for split media.",
"unlimited_duration": "Unlimited",
"schedule": "Schedule",
"select_time": "Select time",
@@ -3431,10 +3449,10 @@
"search": {
"placeholder": "Search"
},
"filters": {
"newest_first": "Newest first",
"pick_a_date": "Pick a date",
"title": "Filters",
"filters": {
"newest_first": "Newest first",
"pick_a_date": "Pick a date",
"title": "Filters",
"search_by": "Search by",
"since": "Since",
"until": "Until",
@@ -3449,7 +3467,7 @@
"started_typing": "Started typing",
"stopped_typing": "Stopped typing",
"started_speaking": "Started speaking",
"stopped_speaking": "Stopped speaking",
"stopped_speaking": "Stopped speaking",
"started_peeking": "Started peeking",
"stopped_peeking": "Stopped peeking",
"message_read": "Read message",

View File

@@ -205,6 +205,12 @@ class MessagingTweaks : ConfigContainer() {
val unlimitedSnapViewTime = boolean("unlimited_snap_view_time")
val autoMarkAsRead = multiple("auto_mark_as_read", "snap_reply", "conversation_read", "save_snap_in_chat") { requireRestart() }
val markSnapAsSeenButton = boolean("mark_snap_as_seen_button") { requireRestart() }
val markSnapAsSeenProcessingMode = unique("mark_snap_as_seen_processing_mode", "limit", "complete").apply {
set("limit")
}
val markSnapAsSeenLimit = integer("mark_snap_as_seen_limit", defaultValue = 50) {
inputCheck = { it.toIntOrNull()?.coerceAtLeast(1) != null }
}
val skipWhenMarkingAsSeen = boolean("skip_when_marking_as_seen") { requireRestart() }
val loopMediaPlayback = boolean("loop_media_playback") { requireRestart() }
val disableReplayInFF = boolean("disable_replay_in_ff")

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

@@ -400,6 +400,7 @@ class EndToEndEncryption : MessagingRuleFeature(
context.event.subscribe(SendMessageWithContentEvent::class) { event ->
val messageContent = event.messageContent
val destinations = event.destinations
if (messageContent.contentType != ContentType.CHAT) return@subscribe
val e2eeConversations = destinations.getEndToEndConversations().takeIf { it.isNotEmpty() } ?: return@subscribe
@@ -431,10 +432,6 @@ class EndToEndEncryption : MessagingRuleFeature(
context.longToast(translation["encryption_failed_toast"])
}
}
if (event.messageContent.contentType == ContentType.SNAP) {
event.messageContent.contentType = ContentType.EXTERNAL_MEDIA
}
}
}

View File

@@ -72,6 +72,7 @@ class MediaFilePicker : Feature("Media File Picker") {
private val queuedSplitItemIds = ArrayDeque<String>()
private val queuedSplitCleanupUris = mutableMapOf<String, String>()
private var originalUnsplitItem: Any? = null
private var reusableOriginalItem: Any? = null
private var queuedOverrideType: String? = null
private var bypassSplitOnce = false
private var sendSingleItemHandler: ((Any) -> Boolean)? = null
@@ -80,6 +81,7 @@ class MediaFilePicker : Feature("Media File Picker") {
fun hasQueuedSplitItems(): Boolean = queuedSplitItems.isNotEmpty()
fun hasPendingSplitCleanup(): Boolean = queuedSplitItemIds.isNotEmpty()
fun hasOriginalUnsplitItem(): Boolean = originalUnsplitItem != null
fun hasReusableOriginalItem(): Boolean = reusableOriginalItem != null
fun setQueuedOverrideType(value: String?) {
queuedOverrideType = value
}
@@ -97,6 +99,12 @@ class MediaFilePicker : Feature("Media File Picker") {
originalUnsplitItem = null
queuedOverrideType = null
}
fun sendReusableOriginalItem(): Boolean {
val item = reusableOriginalItem ?: return false
bypassSplitOnce = true
val sender = sendSingleItemHandler ?: return false
return sender(item)
}
private fun queueSplitItems(items: List<Any>, preparedItems: List<PreparedMediaItem>, originalItem: Any?) {
clearQueuedSplitItems(deleteTempItems = false)
originalUnsplitItem = originalItem
@@ -172,6 +180,10 @@ class MediaFilePicker : Feature("Media File Picker") {
val extractor = MediaExtractor()
val muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
val trackMap = mutableMapOf<Int, Int>()
val chunkStartUs = chunkStartMs * 1000
val chunkEndUs = chunkEndMs * 1000
var muxerStarted = false
var wroteAnySample = false
try {
extractor.setDataSource(inputFile.absolutePath)
@@ -201,8 +213,9 @@ class MediaFilePicker : Feature("Media File Picker") {
val buffer = ByteBuffer.allocateDirect(maxBufferSize)
val bufferInfo = android.media.MediaCodec.BufferInfo()
muxer.start()
muxerStarted = true
extractor.seekTo(chunkStartMs * 1000, MediaExtractor.SEEK_TO_PREVIOUS_SYNC)
extractor.seekTo(chunkStartUs, MediaExtractor.SEEK_TO_PREVIOUS_SYNC)
while (true) {
bufferInfo.offset = 0
@@ -211,25 +224,36 @@ class MediaFilePicker : Feature("Media File Picker") {
val sampleTimeUs = extractor.sampleTime
if (sampleTimeUs < 0) break
if (sampleTimeUs >= chunkEndMs * 1000) break
if (sampleTimeUs < chunkStartUs) {
extractor.advance()
continue
}
if (sampleTimeUs >= chunkEndUs) break
val sampleTrackIndex = extractor.sampleTrackIndex
val muxerTrackIndex = trackMap[sampleTrackIndex]
if (muxerTrackIndex != null) {
bufferInfo.presentationTimeUs = sampleTimeUs - (chunkStartMs * 1000)
bufferInfo.presentationTimeUs = sampleTimeUs - chunkStartUs
bufferInfo.flags = extractor.sampleFlags
muxer.writeSampleData(muxerTrackIndex, buffer, bufferInfo)
wroteAnySample = true
}
extractor.advance()
}
outputFiles += outputFile
if (wroteAnySample) {
outputFiles += outputFile
} else {
outputFile.delete()
}
} catch (throwable: Throwable) {
outputFile.delete()
outputFiles.forEach { it.delete() }
throw throwable
} finally {
runCatching { muxer.stop() }
if (muxerStarted) {
runCatching { muxer.stop() }
}
runCatching { muxer.release() }
runCatching { extractor.release() }
}
@@ -422,6 +446,7 @@ class MediaFilePicker : Feature("Media File Picker") {
}
val currentItems = (param.argNullable<Any>(1) as? List<*>)?.filterNotNull() ?: return@hookObjectMethod
if (currentItems.isEmpty()) return@hookObjectMethod
reusableOriginalItem = currentItems.firstOrNull()
val itemClass = sendItems.genericParameterTypes.getOrNull(1)?.getTypeArguments()?.firstOrNull()
?: sendItemsListItemClassFallback

View File

@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.WarningAmber
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.mutableIntStateOf
@@ -39,6 +40,54 @@ import kotlin.random.Random
class AutoMarkAsRead : Feature("Auto Mark As Read") {
val canMarkConversationAsRead by lazy { context.config.messaging.autoMarkAsRead.get().contains("conversation_read") }
private val markAsSeenBatchSize = 50
private val markAsSeenBatchCooldownMs = 4000L
private data class PendingSnapMessage(
val clientMessageId: Long,
val creationTimestamp: Long
)
private fun String?.isRateLimited(): Boolean {
val value = this ?: return false
return value.contains("RESOURCE_EXHAUSTED", ignoreCase = true) ||
value.contains("Rate limited", ignoreCase = true)
}
private fun showRateLimitedDialog(processed: Int, total: Int) {
val activity = context.mainActivity ?: run {
context.inAppOverlay.showStatusToast(
Icons.Default.WarningAmber,
"Rate limited after $processed/$total snaps. Try again later."
)
return
}
createComposeAlertDialog(activity) {
PurrfectOverlayTheme {
PurrfectGlassCard(
modifier = androidx.compose.ui.Modifier.fillMaxWidth(),
title = "Rate Limited",
subtitle = "Snapchat stopped the mark-as-seen run to protect your account",
icon = Icons.Default.WarningAmber
) {
Column(
modifier = androidx.compose.ui.Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Processed $processed of $total snaps before the request was rate limited.",
color = PurrfectOverlayPalette.textSecondary
)
Text(
text = "No bypass was attempted. Wait a bit and run it again, or lower the per-run limit in settings.",
color = PurrfectOverlayPalette.textSecondary
)
}
}
}
}.show()
}
fun markConversationsAsRead(conversationIds: List<String>) {
conversationIds.forEach { conversationId ->
@@ -63,18 +112,66 @@ class AutoMarkAsRead : Feature("Auto Mark As Read") {
}
}
private fun getPendingSnapMessageIds(conversationId: String, requestedLimit: Int?): List<Long> {
val collected = mutableListOf<PendingSnapMessage>()
val pageSize = 200
var page = 0
while (true) {
val messages = context.database.getMessagesFromConversationId(conversationId, pageSize, page) ?: break
messages.forEach { message ->
if (message.contentType != ContentType.SNAP.id && message.contentType != ContentType.EXTERNAL_MEDIA.id) return@forEach
if (message.isViewedByUser == 1 || message.readTimestamp > 0L) return@forEach
collected += PendingSnapMessage(
clientMessageId = message.clientMessageId.toLong(),
creationTimestamp = message.creationTimestamp
)
}
if (messages.size < pageSize) break
if (requestedLimit != null && collected.size >= requestedLimit) break
page++
}
return collected
.distinctBy { it.clientMessageId }
.sortedBy { it.creationTimestamp }
.let { pending ->
if (requestedLimit != null) pending.take(requestedLimit) else pending
}
.map { it.clientMessageId }
}
fun markSnapsAsSeen(conversationId: String) {
val messaging = context.feature(Messaging::class)
val messageIds = messaging.getFeedCachedMessageIds(conversationId)?.takeIf { it.isNotEmpty() } ?: run {
context.inAppOverlay.showStatusToast(
Icons.Default.WarningAmber,
context.translation["mark_as_seen.no_unseen_snaps_toast"]
)
return
val processingMode = context.config.messaging.markSnapAsSeenProcessingMode.get()
val configuredLimit = context.config.messaging.markSnapAsSeenLimit.get()
.coerceAtLeast(1)
val requestedLimit = if (processingMode == "complete") null else configuredLimit
val messageIds = getPendingSnapMessageIds(conversationId, requestedLimit)
.ifEmpty {
messaging.getFeedCachedMessageIds(conversationId)
?.map { it.toLong() }
?.takeIf { it.isNotEmpty() }
?.let { cached ->
if (requestedLimit != null) cached.take(requestedLimit) else cached
}
?: run {
context.inAppOverlay.showStatusToast(
Icons.Default.WarningAmber,
context.translation["mark_as_seen.no_unseen_snaps_toast"]
)
return
}
}
val targetMessageIds = if (requestedLimit == null) {
messageIds
} else {
messageIds.take(requestedLimit)
}
var job: Job? = null
val processedCount = mutableIntStateOf(0)
var rateLimitedAt: Int? = null
val dialog = createComposeAlertDialog(context.mainActivity!!, builder = {
setOnDismissListener { job?.cancel() }
}) {
@@ -95,7 +192,7 @@ class AutoMarkAsRead : Feature("Auto Mark As Read") {
trackColor = PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.18f)
)
Text(
text = "${processedCount.intValue}/${messageIds.size}",
text = "${processedCount.intValue}/${targetMessageIds.size}",
color = PurrfectOverlayPalette.textSecondary
)
}
@@ -104,16 +201,34 @@ class AutoMarkAsRead : Feature("Auto Mark As Read") {
}.apply { show() }
context.coroutineScope.launch(Dispatchers.IO) {
messageIds.forEachIndexed { index, messageId ->
markSnapAsSeen(conversationId, messageId)
targetMessageIds.forEachIndexed { index, messageId ->
val result = markSnapAsSeen(conversationId, messageId)
if (result.isRateLimited()) {
rateLimitedAt = processedCount.intValue
return@launch
}
delay(Random.nextLong(20, 60))
context.runOnUiThread {
processedCount.intValue = index + 1
}
val processed = index + 1
if (processed < targetMessageIds.size && processed % markAsSeenBatchSize == 0) {
delay(markAsSeenBatchCooldownMs)
}
}
}.also { job = it }.invokeOnCompletion {
context.runOnUiThread {
dialog.dismiss()
if (rateLimitedAt != null) {
val processedIndex = rateLimitedAt!!
processedCount.intValue = processedIndex
showRateLimitedDialog(processedIndex, targetMessageIds.size)
} else if (requestedLimit != null && targetMessageIds.size < messageIds.size) {
context.inAppOverlay.showStatusToast(
Icons.Default.Info,
"Processed ${targetMessageIds.size} of ${messageIds.size} unseen snaps."
)
}
}
}
}

View File

@@ -22,6 +22,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.ui.text.input.KeyboardType
import kotlinx.coroutines.*
import me.eternal.purrfectsnap.bridge.task.TaskListener
import me.eternal.purrfectsnap.common.data.ContentType
@@ -50,6 +53,8 @@ import me.eternal.purrfectsnap.core.util.hook.hookConstructor
import me.eternal.purrfectsnap.mapper.impl.CallbackMapper
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Collections
import java.util.IdentityHashMap
import java.util.Locale
import kotlin.time.DurationUnit
import kotlin.time.toDuration
@@ -60,6 +65,40 @@ class SendOverride : Feature("Send Override") {
companion object {
private const val NOTIFICATION_CHANNEL_ID = "scheduled_send"
private val internalMultipartSend = ThreadLocal.withInitial { false }
private var queuedOriginalItemRepeatCount = 0
private var queuedOriginalItemRepeatOverrideType: String? = null
private fun queueOriginalItemRepeats(repeatCount: Int, overrideType: String) {
queuedOriginalItemRepeatCount = repeatCount
queuedOriginalItemRepeatOverrideType = overrideType
MediaFilePicker.setQueuedOverrideType(overrideType)
}
private fun clearQueuedOriginalItemRepeats() {
queuedOriginalItemRepeatCount = 0
queuedOriginalItemRepeatOverrideType = null
}
private fun handleQueuedOriginalItemRepeatSuccess(): Boolean {
if (queuedOriginalItemRepeatCount <= 0) {
clearQueuedOriginalItemRepeats()
return false
}
val overrideType = queuedOriginalItemRepeatOverrideType ?: run {
clearQueuedOriginalItemRepeats()
return false
}
queuedOriginalItemRepeatCount--
MediaFilePicker.setQueuedOverrideType(overrideType)
val result = MediaFilePicker.sendReusableOriginalItem()
if (!result) {
queuedOriginalItemRepeatCount++
clearQueuedOriginalItemRepeats()
}
return result
}
}
private var selectedType by mutableStateOf("SNAP")
@@ -405,6 +444,7 @@ class SendOverride : Feature("Send Override") {
val recipientName = recipientNames.joinToString(", ")
event.canceled = true
event.adapter.setResult(null)
fun invokeOriginalAndRestoreResult(ev: SendMessageWithContentEvent) {
val result = ev.adapter.invokeOriginal()
@@ -438,6 +478,20 @@ class SendOverride : Feature("Send Override") {
.first { it.name == "sendMessageWithContent" }
}
val originalMessageJson = context.gson.toJson(localMessageContent.instanceNonNull())
val originalCallback = event.adapter.args().getOrNull(2)
val conversationManagerInstance by lazy {
context.feature(Messaging::class).conversationManager?.instanceNonNull()
}
fun invokeCallbackError(callback: Any?, error: Any?) {
runCatching {
callback?.javaClass?.methods?.firstOrNull { method ->
method.name == "onError" && method.parameterCount == 1
}?.invoke(callback, error)
}
}
fun applyOverride(
targetMessageContent: MessageContent,
targetReader: ProtoReader,
@@ -591,21 +645,98 @@ class SendOverride : Feature("Send Override") {
return true
}
fun sendMedia(overrideType: String, snapDurationMs: Int?): Boolean {
val mediaCount = messageProtoReader.followPath(3)?.getCount(3) ?: 0
fun createMessageContentFromOriginal(): MessageContent {
return MessageContent(
context.gson.fromJson(originalMessageJson, context.classCache.localMessageContent)
).also { messageContent ->
val visited = Collections.newSetFromMap(IdentityHashMap<Any, Boolean>())
fun shouldScrubField(fieldName: String): Boolean {
if (fieldName == "mId") return false
return fieldName in setOf("mMessageId", "mQuotedMessageId") ||
fieldName.contains("AttemptId", ignoreCase = true) ||
fieldName.contains("ClientMessageId", ignoreCase = true) ||
fieldName.contains("ClientId", ignoreCase = true) ||
fieldName.contains("MessageUuid", ignoreCase = true) ||
fieldName.contains("UUID", ignoreCase = true)
}
fun scrubValue(value: Any?) {
if (value == null) return
if (!visited.add(value)) return
when (value) {
is String, is Number, is Boolean, is ByteArray, is Enum<*> -> return
is Iterable<*> -> {
value.forEach { scrubValue(it) }
return
}
is Map<*, *> -> {
value.values.forEach { scrubValue(it) }
return
}
}
sequence<Class<*>> {
var current: Class<*>? = value.javaClass
while (current != null && current != Any::class.java && current != Object::class.java) {
yield(current)
current = current.superclass
}
}.flatMap { it.declaredFields.asSequence() }
.forEach { field ->
runCatching {
field.isAccessible = true
if (shouldScrubField(field.name)) {
when (field.type) {
java.lang.Long.TYPE -> field.setLong(value, 0L)
java.lang.Integer.TYPE -> field.setInt(value, 0)
java.lang.Boolean.TYPE -> field.setBoolean(value, false)
else -> field.set(value, null)
}
} else {
scrubValue(field.get(value))
}
}
}
}
scrubValue(messageContent.instanceNonNull())
}
}
fun invokeSendManually(messageContent: MessageContent, callback: Any?) {
val conversationManager = conversationManagerInstance ?: error("ConversationManager is null")
internalMultipartSend.set(true)
try {
sendMessageWithContentMethod.invoke(
conversationManager,
cloneDestinations(event.destinations),
messageContent.instanceNonNull(),
callback
)
} finally {
internalMultipartSend.set(false)
}
}
fun sendMediaManual(
sourceMessageContent: MessageContent,
overrideType: String,
snapDurationMs: Int?,
completionCallback: Any?
): Boolean {
val sourceReader = ProtoReader(sourceMessageContent.content ?: return false)
val mediaCount = sourceReader.followPath(3)?.getCount(3) ?: 0
if (overrideType != "ORIGINAL" && mediaCount > 1) {
val originalJson = context.gson.toJson(localMessageContent.instanceNonNull())
val originalCallback = event.adapter.args().getOrNull(2)
val mediaBuffers = mutableListOf<ByteArray>()
messageProtoReader.followPath(3)?.eachBuffer { id, buffer ->
sourceReader.followPath(3)?.eachBuffer { id, buffer ->
if (id == 3) mediaBuffers.add(buffer)
}
if (mediaBuffers.isEmpty()) return false
fun buildPartMessageContent(partIndex: Int): MessageContent {
val partContent = MessageContent(
context.gson.fromJson(originalJson, context.classCache.localMessageContent)
)
val partContent = createMessageContentFromOriginal()
val metadata = partContent.instanceNonNull().getObjectFieldOrNull("mExternalContentMetadata")
val refs = ArrayList(partContent.localMediaReferences ?: arrayListOf())
val contentRefs = (metadata?.getObjectFieldOrNull("mContentReferences") as? ArrayList<*>)?.toCollection(ArrayList())
@@ -637,62 +768,95 @@ class SendOverride : Feature("Send Override") {
if (!applyOverride(partContent, partReader, overrideType, snapDurationMs)) return
val callback = if (partIndex == mediaCount - 1) {
originalCallback
completionCallback
} else {
CallbackBuilder(sendMessageCallbackClass)
.override("onSuccess") {
sendPart(partIndex + 1)
}
.override("onError", shouldUnhook = false) {
runCatching {
originalCallback?.javaClass?.methods?.firstOrNull { method ->
method.name == "onError" && method.parameterCount == 1
}?.invoke(originalCallback, it.argNullable<Any>(0))
}
invokeCallbackError(completionCallback, it.argNullable<Any>(0))
}
.build()
}
if (partIndex == 0) {
event.adapter.setArg(1, partContent.instanceNonNull())
event.adapter.setArg(2, callback)
invokeOriginalAndRestoreResult(event)
} else {
internalMultipartSend.set(true)
try {
sendMessageWithContentMethod.invoke(
context.feature(Messaging::class).conversationManager?.instanceNonNull(),
cloneDestinations(event.destinations),
partContent.instanceNonNull(),
callback
)
} finally {
internalMultipartSend.set(false)
}
}
invokeSendManually(partContent, callback)
}
sendPart(0)
return true
}
postSavePolicy = null
val targetReader = ProtoReader(sourceMessageContent.content ?: return false)
if (!applyOverride(sourceMessageContent, targetReader, overrideType, snapDurationMs)) return false
invokeSendManually(sourceMessageContent, completionCallback)
return true
}
fun sendRepeatedMediaManual(
repeatCount: Int,
overrideType: String,
snapDurationMs: Int?
): Boolean {
if (repeatCount <= 0) return false
fun sendIteration(index: Int) {
val callback = if (index == repeatCount - 1) {
originalCallback
} else {
CallbackBuilder(sendMessageCallbackClass)
.override("onSuccess") {
sendIteration(index + 1)
}
.override("onError", shouldUnhook = false) {
invokeCallbackError(originalCallback, it.argNullable<Any>(0))
}
.build()
}
val preparedContent = createMessageContentFromOriginal()
if (!sendMediaManual(preparedContent, overrideType, snapDurationMs, callback)) {
invokeCallbackError(originalCallback, "Failed to send")
}
}
sendIteration(0)
return true
}
fun sendMedia(overrideType: String, snapDurationMs: Int?): Boolean {
postSavePolicy = null
return applyOverride(localMessageContent, messageProtoReader, overrideType, snapDurationMs)
}
val resolvedOverrideType = MediaFilePicker.getQueuedOverrideType()
?: configOverrideType?.takeIf { it != "always_ask" }
if (resolvedOverrideType != null) {
if (MediaFilePicker.hasPendingSplitCleanup() || MediaFilePicker.getQueuedOverrideType() != null) {
event.addCallbackResult("onSuccess") {
context.runOnUiThread {
if (!MediaFilePicker.handleCurrentQueuedItemSuccess()) {
MediaFilePicker.clearQueuedSplitItems()
}
fun attachQueuedRepeatCallbacks(sendEvent: SendMessageWithContentEvent) {
sendEvent.addCallbackResult("onSuccess") {
context.runOnUiThread {
val handledSplit = MediaFilePicker.handleCurrentQueuedItemSuccess()
val handledRepeat = if (!handledSplit) {
handleQueuedOriginalItemRepeatSuccess()
} else {
false
}
if (!handledSplit && !handledRepeat) {
MediaFilePicker.clearQueuedSplitItems()
clearQueuedOriginalItemRepeats()
}
}
event.addCallbackResult("onError") {
MediaFilePicker.clearQueuedSplitItems()
}
}
sendEvent.addCallbackResult("onError") {
MediaFilePicker.clearQueuedSplitItems()
clearQueuedOriginalItemRepeats()
}
}
if (resolvedOverrideType != null) {
if (MediaFilePicker.hasPendingSplitCleanup() || MediaFilePicker.getQueuedOverrideType() != null || queuedOriginalItemRepeatCount > 0) {
attachQueuedRepeatCallbacks(event)
}
if (sendMedia(resolvedOverrideType, 10000)) {
if (event.canceled) invokeOriginalAndRestoreResult(event)
@@ -702,7 +866,7 @@ class SendOverride : Feature("Send Override") {
context.runOnUiThread {
val recipientNameForTask = recipientName
val mediaCount = messageProtoReader.followPath(3)?.getCount(3) ?: 0
val mediaCount = messageProtoReader.followPath(3)?.getCount(3) ?: 0
createComposeAlertDialog(context.mainActivity!!) { alertDialog ->
PurrfectOverlayTheme {
@@ -792,6 +956,8 @@ class SendOverride : Feature("Send Override") {
context.translation.getCategory("features.options.gallery_media_send_override")
}
var scheduleEnabled by remember { mutableStateOf(false) }
var continuousSendEnabled by remember { mutableStateOf(false) }
var continuousSendCount by remember { mutableStateOf("2") }
Text(
fontSize = 20.sp,
@@ -907,6 +1073,42 @@ class SendOverride : Feature("Send Override") {
}
}
Row(
modifier = Modifier.fillMaxWidth().clickable {
continuousSendEnabled = !continuousSendEnabled
},
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = continuousSendEnabled,
onCheckedChange = {
continuousSendEnabled = it
}
)
Text(text = mainTranslation["continuous_send_toggle"], lineHeight = 15.sp)
}
if (continuousSendEnabled) {
OutlinedTextField(
value = continuousSendCount,
onValueChange = { value ->
continuousSendCount = value.filter(Char::isDigit).take(3)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text(mainTranslation["continuous_send_count_label"]) },
placeholder = { Text(mainTranslation["continuous_send_count_placeholder"]) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
keyboardActions = KeyboardActions.Default
)
Text(
text = mainTranslation["continuous_send_hint"],
fontSize = 12.sp,
color = Color.White.copy(alpha = 0.72f)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
@@ -1071,8 +1273,27 @@ class SendOverride : Feature("Send Override") {
Text(context.translation["button.cancel"])
}
Button(onClick = {
alertDialog.dismiss()
val finalSelectedType = selectedType
val repeatCount = if (continuousSendEnabled) {
continuousSendCount.toIntOrNull()?.takeIf { it > 0 }
} else {
1
}
if (repeatCount == null) {
context.inAppOverlay.showStatusToast(
icon = Icons.Default.WarningAmber,
text = mainTranslation["continuous_send_invalid_count"]
)
return@Button
}
if (repeatCount > 1 && disableSplitForCurrentSend && MediaFilePicker.hasOriginalUnsplitItem()) {
context.inAppOverlay.showStatusToast(
icon = Icons.Default.WarningAmber,
text = mainTranslation["continuous_send_single_send_conflict"]
)
return@Button
}
alertDialog.dismiss()
if (disableSplitForCurrentSend && MediaFilePicker.hasOriginalUnsplitItem()) {
MediaFilePicker.setQueuedOverrideType(finalSelectedType)
if (!MediaFilePicker.sendOriginalUnsplitItem()) {
@@ -1137,10 +1358,11 @@ class SendOverride : Feature("Send Override") {
context.bridgeClient.getTaskInterface().updateTaskProgress(taskHash, "Sending...", 100)
if (sendMedia(finalSelectedType, if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) {
if (event.canceled) {
invokeOriginalAndRestoreResult(event)
}
if (sendRepeatedMediaManual(
repeatCount,
finalSelectedType,
if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null
)) {
val successText = context.translation.format("schedule_sent_to", "name" to recipientNameForTask) ?: "Sent to $recipientNameForTask"
context.inAppOverlay.showStatusToast(
icon = Icons.Filled.CheckCircle,
@@ -1187,10 +1409,24 @@ class SendOverride : Feature("Send Override") {
}
}
} else {
if (sendMedia(finalSelectedType, if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) {
if (event.canceled) {
if (repeatCount == 1) {
if (sendMedia(finalSelectedType, if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) {
invokeOriginalAndRestoreResult(event)
}
} else if (MediaFilePicker.hasReusableOriginalItem()) {
queueOriginalItemRepeats(repeatCount - 1, finalSelectedType)
attachQueuedRepeatCallbacks(event)
if (sendMedia(finalSelectedType, if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) {
invokeOriginalAndRestoreResult(event)
} else {
clearQueuedOriginalItemRepeats()
}
} else {
sendRepeatedMediaManual(
repeatCount,
finalSelectedType,
if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null
)
}
}
}) {

View File

@@ -137,4 +137,4 @@ class MessageIndicators : Feature("Message Indicators") {
}
}
}
}
}

View File

@@ -28,6 +28,8 @@ class SnapPreview : Feature("SnapPreview") {
private val bitmapCache = EvictingMap<String, Bitmap>(50) // filePath => bitmap
private val fetchJobTab = randomTag()
private val previewHorizontalAdjustmentDp = 16
private val previewVerticalAdjustmentDp = 10
override fun init() {
if (!context.config.userInterface.snapPreview.get()) return
@@ -47,9 +49,11 @@ class SnapPreview : Feature("SnapPreview") {
}
onNextActivityCreate {
val (chatMediaCardHeight, chatMediaCardSnapMargin, chatMediaCardSnapMarginStartSdl) = context.userInterface.run {
Triple(dpToPx(60), dpToPx(10), dpToPx(15))
}
val chatMediaCardHeight = context.userInterface.dpToPx(60)
val chatMediaCardSnapMargin = context.userInterface.dpToPx(10)
val chatMediaCardSnapMarginStartSdl = context.userInterface.dpToPx(15)
val previewHorizontalAdjustment = context.userInterface.dpToPx(previewHorizontalAdjustmentDp)
val previewVerticalAdjustment = context.userInterface.dpToPx(previewVerticalAdjustmentDp)
fun decodeMedia(file: File) = runCatching {
bitmapCache.getOrPut(file.absolutePath) {
@@ -91,8 +95,8 @@ class SnapPreview : Feature("SnapPreview") {
val bitmap = bitmapCache[mediaFilePath] ?: return
canvas.drawBitmap(bitmap,
canvas.width.toFloat() - bitmap.width - chatMediaCardSnapMarginStartSdl.toFloat() - chatMediaCardSnapMargin.toFloat(),
(canvas.height - bitmap.height) / 2f,
canvas.width.toFloat() - bitmap.width - chatMediaCardSnapMarginStartSdl.toFloat() - chatMediaCardSnapMargin.toFloat() + previewHorizontalAdjustment,
(canvas.height - bitmap.height) / 2f + previewVerticalAdjustment,
null
)
}
@@ -101,4 +105,4 @@ class SnapPreview : Feature("SnapPreview") {
}
}
}
}
}

View File

@@ -7,10 +7,11 @@ org.gradle.configuration-cache=true
org.gradle.configuration-cache.problems=warn
nativeAbis=arm64-v8a
APP_VERSION_NAME=1.5.1
APP_VERSION_CODE=294
APP_VERSION_NAME=1.5.4
APP_VERSION_CODE=300
debug_build_hash=18fe2a814d0e2eb5
psIntegrityPinnedSha256=
EXPECTED_CERT_SHA256=0f188cb17d8ea4902ee15ce98f4928ba4226a4790fa3c52f62d776b08e46ca1c
android.disallowKotlinSourceSets=false
android.sourceset.disallowProvider=false
ksp.incremental=false