8 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
ΞTΞRNAL
901e4f81b8 fix: Missing changes 2026-03-22 20:32:16 +05:30
ΞTΞRNAL
a4e86b5ab4 fix(PR): Aphelion task page layout optimization by Kaladin
Aphelion task page layout optimization
2026-03-22 20:31:25 +05:30
ΞTΞRNAL
bdc6d12739 fix: splitting issue for snaps sent through send override 2026-03-22 20:30:34 +05:30
DarkKnight2122
6c8d5297c8 Task page layout harmonization. 2026-03-22 19:43:20 +05:30
21 changed files with 1336 additions and 93 deletions

1
.gitignore vendored
View File

@@ -20,3 +20,4 @@ security/allowed_codes.local.*
valdi/node_modules/
hs_err_pid*.log
replay_pid*.log
.vs

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

@@ -97,6 +97,13 @@ fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) {
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
val scrollOffset = routes.navigation?.globalScrollOffset ?: 0
val focusFactor = (scrollOffset.toFloat() / Motion.HEADER_MORPH_THRESHOLD).coerceIn(0f, 1f)
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
val containerTopPadding = androidx.compose.ui.unit.lerp(statusBarHeight + 2.dp, 0.dp, focusFactor)
val topCorners = androidx.compose.ui.unit.lerp(28.dp, 0.dp, focusFactor)
val subtitle = if (activeTasks.isNotEmpty()) {
translation.format(
"summary_active",
@@ -110,13 +117,13 @@ fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) {
)
}
// The "Structured Glass" Container (1:1 with build 33a7e8f)
// The "Structured Glass" Container (Dynamically Morphed)
Surface(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 12.dp)
.padding(top = 12.dp),
shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp, bottomStart = 0.dp, bottomEnd = 0.dp),
.padding(top = containerTopPadding),
shape = RoundedCornerShape(topStart = topCorners, topEnd = topCorners, bottomStart = 0.dp, bottomEnd = 0.dp),
color = Color.White.copy(alpha = 0.04f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
@@ -128,7 +135,7 @@ fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) {
contentPadding = PaddingValues(
start = 10.dp,
end = 10.dp,
top = controlsHeight,
top = controlsHeight - 44.dp,
bottom = routes.bottomPadding + 20.dp
),
verticalArrangement = Arrangement.spacedBy(16.dp)

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.0").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("292").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,23 @@
## 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
- Fix: Aphelion task page layout optimization(tq to Kaladin)
## v1.5.0
- Fix: Skip when marking as seen for newer versions of Snapchat
- New: Hide Conversation Toolbox UI

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",
@@ -3327,6 +3339,13 @@
"title": "Send media as",
"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",
@@ -3430,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",
@@ -3448,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

@@ -2,12 +2,18 @@ package me.eternal.purrfectsnap.core.features.impl.experiments
import android.annotation.SuppressLint
import android.app.Activity
import android.content.ContentUris
import android.content.ContentResolver
import android.content.ContentValues
import android.content.Intent
import android.database.Cursor
import android.database.CursorWrapper
import android.media.MediaExtractor
import android.media.MediaFormat
import android.media.MediaMetadataRetriever
import android.media.MediaMuxer
import android.net.Uri
import android.os.Build
import android.os.ParcelFileDescriptor
import android.provider.MediaStore
import android.webkit.MimeTypeMap
@@ -36,6 +42,7 @@ import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.common.data.FileType
import me.eternal.purrfectsnap.common.ui.createComposeView
@@ -47,17 +54,308 @@ import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme
import me.eternal.purrfectsnap.core.util.dataBuilder
import me.eternal.purrfectsnap.core.util.hook.Hooker
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.util.ktx.getObjectFieldOrNull
import me.eternal.purrfectsnap.mapper.impl.ChatMediaDrawerMapper
import java.io.File
import java.io.InputStream
import java.lang.reflect.Method
import java.nio.ByteBuffer
import kotlin.random.Random
class MediaFilePicker : Feature("Media File Picker") {
companion object {
private const val SNAP_CHUNK_DURATION_MS = 10_000L
private val queuedSplitItems = ArrayDeque<Any>()
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
private var cleanupItemHandler: ((String) -> Unit)? = null
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
}
fun getQueuedOverrideType(): String? = queuedOverrideType
fun clearQueuedSplitItems(deleteTempItems: Boolean = true) {
if (deleteTempItems) {
val cleanup = cleanupItemHandler
queuedSplitCleanupUris.values.toList().forEach { uri ->
cleanup?.invoke(uri)
}
}
queuedSplitItems.clear()
queuedSplitItemIds.clear()
queuedSplitCleanupUris.clear()
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
items.drop(1).forEach { queuedSplitItems.addLast(it) }
preparedItems.forEach {
queuedSplitItemIds.addLast(it.itemId)
queuedSplitCleanupUris[it.itemId] = it.uri
}
}
fun sendOriginalUnsplitItem(): Boolean {
val item = originalUnsplitItem ?: return false
val overrideType = queuedOverrideType
clearQueuedSplitItems(deleteTempItems = true)
queuedOverrideType = overrideType
bypassSplitOnce = true
val sender = sendSingleItemHandler ?: return false
return sender(item)
}
fun handleCurrentQueuedItemSuccess(): Boolean {
queuedSplitItemIds.removeFirstOrNull()?.let { itemId ->
queuedSplitCleanupUris.remove(itemId)?.let { uri ->
cleanupItemHandler?.invoke(uri)
}
}
if (queuedSplitItems.isEmpty()) {
queuedOverrideType = null
return false
}
val next = queuedSplitItems.removeFirstOrNull() ?: run {
queuedOverrideType = null
return false
}
val sender = sendSingleItemHandler ?: return false
val result = sender(next)
if (!result) {
queuedSplitItems.addFirst(next)
}
return result
}
}
var lastMediaDuration: Long? = null
private set
private data class PreparedMediaItem(
val itemId: String,
val durationMs: Long,
val uri: String
)
private fun splitVideoIntoChunks(
inputFile: File,
chunkDurationMs: Long = SNAP_CHUNK_DURATION_MS
): List<File> {
val durationMs = extractMediaDuration(Uri.fromFile(inputFile)) ?: return emptyList()
if (durationMs <= chunkDurationMs) return listOf(inputFile)
val retriever = MediaMetadataRetriever()
val rotation = runCatching {
retriever.setDataSource(inputFile.absolutePath)
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toIntOrNull() ?: 0
}.getOrDefault(0).also {
runCatching { retriever.release() }
}
val outputFiles = mutableListOf<File>()
var chunkStartMs = 0L
var chunkIndex = 0
while (chunkStartMs < durationMs) {
val chunkEndMs = minOf(chunkStartMs + chunkDurationMs, durationMs)
val outputFile = File.createTempFile("purrfectsnap_chunk_${chunkIndex}_", ".mp4", context.androidContext.cacheDir)
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)
repeat(extractor.trackCount) { trackIndex ->
val format = extractor.getTrackFormat(trackIndex)
val mime = format.getString(MediaFormat.KEY_MIME) ?: return@repeat
if (!mime.startsWith("video/") && !mime.startsWith("audio/")) return@repeat
extractor.selectTrack(trackIndex)
trackMap[trackIndex] = muxer.addTrack(format)
}
if (rotation != 0) {
muxer.setOrientationHint(rotation)
}
val maxBufferSize = (0 until extractor.trackCount).maxOfOrNull { trackIndex ->
extractor.getTrackFormat(trackIndex).let { format ->
if (format.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) {
format.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE)
} else {
1024 * 1024
}
}
} ?: (1024 * 1024)
val buffer = ByteBuffer.allocateDirect(maxBufferSize)
val bufferInfo = android.media.MediaCodec.BufferInfo()
muxer.start()
muxerStarted = true
extractor.seekTo(chunkStartUs, MediaExtractor.SEEK_TO_PREVIOUS_SYNC)
while (true) {
bufferInfo.offset = 0
bufferInfo.size = extractor.readSampleData(buffer, 0)
if (bufferInfo.size < 0) break
val sampleTimeUs = extractor.sampleTime
if (sampleTimeUs < 0) 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 - chunkStartUs
bufferInfo.flags = extractor.sampleFlags
muxer.writeSampleData(muxerTrackIndex, buffer, bufferInfo)
wroteAnySample = true
}
extractor.advance()
}
if (wroteAnySample) {
outputFiles += outputFile
} else {
outputFile.delete()
}
} catch (throwable: Throwable) {
outputFile.delete()
outputFiles.forEach { it.delete() }
throw throwable
} finally {
if (muxerStarted) {
runCatching { muxer.stop() }
}
runCatching { muxer.release() }
runCatching { extractor.release() }
}
chunkStartMs += chunkDurationMs
chunkIndex++
}
return outputFiles
}
private fun registerTemporaryVideo(file: File, displayName: String): PreparedMediaItem {
val resolver = context.androidContext.contentResolver
val values = ContentValues().apply {
put(MediaStore.Video.Media.DISPLAY_NAME, displayName)
put(MediaStore.Video.Media.MIME_TYPE, "video/mp4")
put(MediaStore.Video.Media.RELATIVE_PATH, "Movies/.PurrfectSnap")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.Video.Media.IS_PENDING, 1)
}
}
val uri = resolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values)
?: error("Failed to create MediaStore entry")
runCatching {
resolver.openOutputStream(uri)?.use { output ->
file.inputStream().use { input -> input.copyTo(output) }
} ?: error("Failed to open MediaStore output stream")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
resolver.update(uri, ContentValues().apply {
put(MediaStore.Video.Media.IS_PENDING, 0)
}, null, null)
}
}.onFailure {
resolver.delete(uri, null, null)
throw it
}
val durationMs = extractMediaDuration(uri) ?: 0L
val itemId = uri.lastPathSegment ?: error("Failed to resolve MediaStore item id")
context.coroutineScope.launch {
delay(120_000)
runCatching { resolver.delete(uri, null, null) }
}
return PreparedMediaItem(itemId = itemId, durationMs = durationMs, uri = uri.toString())
}
private fun buildDrawerItems(itemClass: Any, mediaItems: List<PreparedMediaItem>): List<Any> {
return mediaItems.mapIndexedNotNull { index, mediaItem ->
itemClass.dataBuilder {
from("_item") {
set("_cameraRollSource", "Snapchat")
set("_contentUri", "")
set("_durationMs", mediaItem.durationMs.toDouble())
set("_disabled", false)
set("_imageRotation", 0.0)
set("_width", 1080.0)
set("_height", 1920.0)
set("_timestampMs", (System.currentTimeMillis() + index).toDouble())
from("_itemId") {
set("_itemId", mediaItem.itemId)
set("_type", "VIDEO")
}
}
set("_order", index.toDouble())
}
}
}
private fun prepareChunkedItemsFromMediaStoreId(itemId: String, durationMs: Long): List<PreparedMediaItem>? {
val numericId = itemId.toLongOrNull() ?: return null
val effectiveDurationMs = durationMs.takeIf { it > 0 } ?: extractMediaDuration(
ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, numericId)
) ?: return null
if (effectiveDurationMs <= SNAP_CHUNK_DURATION_MS) return null
val sourceUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, numericId)
val sourceFile = File.createTempFile("purrfectsnap_gallery_source_", ".mp4", context.androidContext.cacheDir)
return runCatching {
context.androidContext.contentResolver.openInputStream(sourceUri)?.use { input ->
sourceFile.outputStream().use { output -> input.copyTo(output) }
} ?: error("Failed to open source gallery video")
val chunkFiles = splitVideoIntoChunks(sourceFile, SNAP_CHUNK_DURATION_MS)
val preparedItems = chunkFiles.mapIndexed { index, file ->
registerTemporaryVideo(file, "purrfectsnap_gallery_chunk_${System.currentTimeMillis()}_$index.mp4")
}
chunkFiles.forEach { if (it != sourceFile) it.delete() }
preparedItems
}.also {
sourceFile.delete()
}.getOrElse {
context.log.error("Failed to prepare split gallery items", it)
null
}
}
private fun extractMediaDuration(uri: Uri): Long? {
val retriever = MediaMetadataRetriever()
return runCatching {
@@ -96,6 +394,7 @@ class MediaFilePicker : Feature("Media File Picker") {
var sendItemsMethod: Method? = null
var drawerViewClass: Class<*>? = null
var sendItemsListItemClassFallback: Class<*>? = null
var sendItemsHookedHandler: Any? = null
context.mappings.useMapper(ChatMediaDrawerMapper::class) {
val drawerCls = chatMediaDrawerClass.getAsClass() ?: return@useMapper
@@ -115,6 +414,72 @@ class MediaFilePicker : Feature("Media File Picker") {
sendItemsMethod = sendItems
handlerParamMethod.hook(HookStage.AFTER) {
chatMediaDrawerActionHandler = it.arg(0)
val handlerInstance = chatMediaDrawerActionHandler
sendSingleItemHandler = sendSingleItem@{ item ->
runCatching {
sendItemsMethod?.invoke(chatMediaDrawerActionHandler, listOf<Any>(), listOf(item))
true
}.getOrElse { throwable ->
context.log.error("MediaFilePicker: Failed to send queued split item", throwable)
false
}
}
cleanupItemHandler = { uriString ->
runCatching {
context.androidContext.contentResolver.delete(Uri.parse(uriString), null, null)
}.onFailure {
context.log.warn("MediaFilePicker: Failed to delete temp split media: ${it.message}")
}
}
if (sendItemsHookedHandler === handlerInstance) return@hook
sendItemsHookedHandler = handlerInstance
Hooker.hookObjectMethod(
handlerInstance::class.java,
handlerInstance,
sendItemsName,
HookStage.BEFORE
) { param ->
if (bypassSplitOnce) {
bypassSplitOnce = false
return@hookObjectMethod
}
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
?: currentItems.firstOrNull()?.javaClass
?: return@hookObjectMethod
val preparedExpandedItems = mutableListOf<PreparedMediaItem>()
var didExpand = false
val expandedItems = currentItems.flatMap { item ->
val baseItem = item.getObjectFieldOrNull("_item") ?: return@flatMap listOf(item)
val durationMs = ((baseItem.getObjectFieldOrNull("_durationMs") as? Double)?.toLong())
?: ((baseItem.getObjectFieldOrNull("_durationMs") as? Long))
?: 0L
val itemId = baseItem.getObjectFieldOrNull("_itemId")
?.getObjectFieldOrNull("_itemId")
?.toString()
?: return@flatMap listOf(item)
val splitItems = prepareChunkedItemsFromMediaStoreId(itemId, durationMs)
if (splitItems.isNullOrEmpty()) {
listOf(item)
} else {
didExpand = true
preparedExpandedItems.addAll(splitItems)
buildDrawerItems(itemClass, splitItems)
}
}
if (didExpand && expandedItems.isNotEmpty()) {
queueSplitItems(expandedItems, preparedExpandedItems, currentItems.firstOrNull())
param.setArg(1, listOf(expandedItems.first()))
}
}
}
}
@@ -171,7 +536,7 @@ class MediaFilePicker : Feature("Media File Picker") {
return@subscribe
}
fun sendMedia() {
fun sendMedia(items: List<PreparedMediaItem>? = null) {
val method = sendItemsMethod ?: return
val itemClass = method.genericParameterTypes.getOrNull(1)?.getTypeArguments()?.firstOrNull()
?: sendItemsListItemClassFallback
@@ -180,27 +545,13 @@ class MediaFilePicker : Feature("Media File Picker") {
context.inAppOverlay.showStatusToast(Icons.Default.Error, "Failed to send media (incompatible version).")
return
}
val item = itemClass.dataBuilder {
from("_item") {
set("_cameraRollSource", "Snapchat")
set("_contentUri", "")
set("_durationMs", (lastMediaDuration ?: 0L).toDouble())
set("_disabled", false)
set("_imageRotation", 0.0)
set("_width", 1080.0)
set("_height", 1920.0)
set("_timestampMs", System.currentTimeMillis().toDouble())
from("_itemId") {
set("_itemId", firstVideoId.toString())
set("_type", "VIDEO")
}
}
set("_order", 0.0)
} ?: run {
val mediaItems = items ?: listOf(PreparedMediaItem(firstVideoId.toString(), lastMediaDuration ?: 0L, ""))
val builtItems = buildDrawerItems(itemClass, mediaItems)
if (builtItems.size != mediaItems.size) {
context.inAppOverlay.showStatusToast(Icons.Default.Error, "Failed to build media item.")
return
}
method.invoke(chatMediaDrawerActionHandler, listOf<Any>(), listOf(item))
method.invoke(chatMediaDrawerActionHandler, listOf<Any>(), builtItems)
}
fun startConversion(audioOnly: Boolean) {
@@ -238,8 +589,25 @@ class MediaFilePicker : Feature("Media File Picker") {
context.inAppOverlay.showStatusToast(Icons.Default.CheckCircleOutline, "Media converted successfully.")
runCatching {
mediaInputStream = ParcelFileDescriptor.AutoCloseInputStream(pfd)
sendMedia()
if (!audioOnly && (lastMediaDuration ?: 0L) > 10_000L) {
val convertedFile = File.createTempFile("purrfectsnap_source_", ".$outputExtension", context.androidContext.cacheDir)
ParcelFileDescriptor.AutoCloseInputStream(pfd).use { input ->
convertedFile.outputStream().use { output -> input.copyTo(output) }
}
val chunkFiles = splitVideoIntoChunks(convertedFile)
val preparedItems = chunkFiles.mapIndexed { index, file ->
registerTemporaryVideo(file, "purrfectsnap_chunk_${System.currentTimeMillis()}_$index.mp4")
}
chunkFiles.forEach { if (it != convertedFile) it.delete() }
convertedFile.delete()
sendMedia(preparedItems)
} else {
mediaInputStream = ParcelFileDescriptor.AutoCloseInputStream(pfd)
sendMedia()
}
}.onFailure {
mediaInputStream = null
context.log.error(it)

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
@@ -32,19 +35,26 @@ import me.eternal.purrfectsnap.common.util.protobuf.ProtoWriter
import me.eternal.purrfectsnap.core.event.events.impl.MediaUploadEvent
import me.eternal.purrfectsnap.core.event.events.impl.NativeUnaryCallEvent
import me.eternal.purrfectsnap.core.event.events.impl.SendMessageWithContentEvent
import me.eternal.purrfectsnap.core.event.events.impl.UnaryCallEvent
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.features.impl.experiments.MediaFilePicker
import me.eternal.purrfectsnap.core.messaging.MessageSender
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme
import me.eternal.purrfectsnap.core.wrapper.impl.MessageContent
import me.eternal.purrfectsnap.core.wrapper.impl.MessageDestinations
import me.eternal.purrfectsnap.core.util.ktx.getObjectFieldOrNull
import me.eternal.purrfectsnap.core.util.ktx.setObjectField
import me.eternal.purrfectsnap.core.util.CallbackBuilder
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.Hooker
import me.eternal.purrfectsnap.core.util.hook.hook
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
@@ -54,9 +64,45 @@ import kotlin.time.toDuration
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")
private var disableSplitForCurrentSend by mutableStateOf(false)
private var customDuration by mutableFloatStateOf(10f)
private var scheduledTime by mutableStateOf<Long?>(null)
private var showClockPicker by mutableStateOf(false)
@@ -66,7 +112,6 @@ class SendOverride : Feature("Send Override") {
private val backgroundHookLock = Any()
private var backgroundHookRefs = 0
private var backgroundHooks: List<Hooker.HookHandle>? = null
private fun acquireScheduledSendBackground(): () -> Unit {
if (!context.config.messaging.scheduledSendAllowRunningInBackground.get()) return {}
var enableFailed = false
@@ -362,7 +407,12 @@ class SendOverride : Feature("Send Override") {
}
}
context.event.subscribe(UnaryCallEvent::class, priority = 100) { event ->
if (event.uri != "/messagingcoreservice.MessagingCoreService/CreateContentMessage") return@subscribe
}
context.event.subscribe(SendMessageWithContentEvent::class, priority = -100) { event ->
if (internalMultipartSend.get() == true) return@subscribe
postSavePolicy = null
if (event.destinations.stories?.isNotEmpty() == true && event.destinations.conversations?.isEmpty() == true) return@subscribe
val localMessageContent = event.messageContent
@@ -394,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()
@@ -401,9 +452,54 @@ class SendOverride : Feature("Send Override") {
ev.canceled = false
}
fun sendMedia(overrideType: String, snapDurationMs: Int?): Boolean {
val sendMessageCallbackClass by lazy {
lateinit var result: Class<*>
context.mappings.useMapper(CallbackMapper::class) {
result = callbacks.getClass("SendMessageCallback") ?: error("Failed to resolve SendMessageCallback")
}
result
}
fun cloneDestinations(source: MessageDestinations): Any {
return context.gson.fromJson(
context.gson.toJson(source.instanceNonNull()),
context.classCache.messageDestinations
)
}
val sendMessageWithContentMethod by lazy {
sequence {
var current: Class<*>? = context.classCache.conversationManager
while (current != null && current != Any::class.java && current != Object::class.java) {
yield(current)
current = current.superclass
}
}.flatMap { it.declaredMethods.asSequence() }
.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,
overrideType: String,
snapDurationMs: Int?
): Boolean {
val bypassLimit = context.config.experimental.nativeHooks.valdiHooks.bypassCameraRollLimit.get()
if (overrideType != "ORIGINAL" && !bypassLimit && (messageProtoReader.followPath(3)?.getCount(3) ?: 0) > 1) {
if (overrideType != "ORIGINAL" && !bypassLimit && (targetReader.followPath(3)?.getCount(3) ?: 0) > 1) {
context.inAppOverlay.showStatusToast(
icon = Icons.Default.WarningAmber,
context.translation["gallery_media_send_override.multiple_media_toast"]
@@ -416,10 +512,10 @@ class SendOverride : Feature("Send Override") {
val savePolicyValue = if (overrideType == "SAVEABLE_SNAP") 2 else 1
postSavePolicy = savePolicyValue
val extras = messageProtoReader.followPath(3, 3, 13)?.getBuffer()
val extras = targetReader.followPath(3, 3, 13)?.getBuffer()
if (localMessageContent.contentType != ContentType.SNAP) {
localMessageContent.content = ProtoWriter().apply {
if (targetMessageContent.contentType != ContentType.SNAP) {
targetMessageContent.content = ProtoWriter().apply {
from(11) {
from(5) {
from(1) {
@@ -440,11 +536,11 @@ class SendOverride : Feature("Send Override") {
}.toByteArray()
}
localMessageContent.contentType = ContentType.SNAP
localMessageContent.content = ProtoEditor(localMessageContent.content!!).apply {
targetMessageContent.contentType = ContentType.SNAP
targetMessageContent.content = ProtoEditor(targetMessageContent.content!!).apply {
edit(11, 5, 2) {
arrayOf(6, 7, 8).forEach { remove(it) }
addVarInt(5, messageProtoReader.getVarInt(3, 3, 5, 2, 5) ?: messageProtoReader.getVarInt(11, 5, 2, 5) ?: 1)
addVarInt(5, targetReader.getVarInt(3, 3, 5, 2, 5) ?: targetReader.getVarInt(11, 5, 2, 5) ?: 1)
if (snapDurationMs != null && overrideType != "SAVEABLE_SNAP") {
addVarInt(8, snapDurationMs / 1000)
if (snapDurationMs / 1000 <= 0) {
@@ -474,11 +570,11 @@ class SendOverride : Feature("Send Override") {
if (shouldPreventSave) {
postSavePolicy = 1 // PROHIBITED
}
localMessageContent.contentType = ContentType.NOTE
targetMessageContent.contentType = ContentType.NOTE
val stripMeta = context.config.messaging.stripMediaMetadata.get()
val omitTranscript = stripMeta.contains("remove_audio_note_transcript_capability")
val rawDurationMs = messageProtoReader.getVarInt(3, 3, 5, 1, 1, 15)?.toLong()
?: messageProtoReader.getVarInt(3, 3, 5, 2, 8)?.toLong()?.times(1000)
val rawDurationMs = targetReader.getVarInt(3, 3, 5, 1, 1, 15)?.toLong()
?: targetReader.getVarInt(3, 3, 5, 2, 8)?.toLong()?.times(1000)
?: (context.feature(MediaFilePicker::class).lastMediaDuration ?: 0).toLong()
val durationForProto = minOf(rawDurationMs, MessageSender.VOICE_NOTE_MAX_DURATION_MS)
val audioNoteProto = MessageSender.audioNoteProto(
@@ -487,7 +583,7 @@ class SendOverride : Feature("Send Override") {
)
// Set save policy in the proto if prevent audio is enabled
localMessageContent.content = if (shouldPreventSave) {
targetMessageContent.content = if (shouldPreventSave) {
// Check which path structure exists in the audio note proto
val protoReader = ProtoReader(audioNoteProto)
val hasNestedPath = protoReader.followPath(6, 1, 1) != null
@@ -519,7 +615,7 @@ class SendOverride : Feature("Send Override") {
Class.forName(
"com.snapchat.client.messaging.SavePolicy",
false,
localMessageContent.instanceNonNull().javaClass.classLoader
targetMessageContent.instanceNonNull().javaClass.classLoader
)
}.getOrNull()
@@ -537,7 +633,7 @@ class SendOverride : Feature("Send Override") {
}.getOrNull()
if (policyEnum != null) {
localMessageContent.instanceNonNull().setObjectField("mSavePolicy", policyEnum)
targetMessageContent.instanceNonNull().setObjectField("mSavePolicy", policyEnum)
}
}
}
@@ -549,17 +645,228 @@ class SendOverride : Feature("Send Override") {
return true
}
val resolvedOverrideType = configOverrideType?.takeIf { it != "always_ask" }
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 mediaBuffers = mutableListOf<ByteArray>()
sourceReader.followPath(3)?.eachBuffer { id, buffer ->
if (id == 3) mediaBuffers.add(buffer)
}
if (mediaBuffers.isEmpty()) return false
fun buildPartMessageContent(partIndex: Int): MessageContent {
val partContent = createMessageContentFromOriginal()
val metadata = partContent.instanceNonNull().getObjectFieldOrNull("mExternalContentMetadata")
val refs = ArrayList(partContent.localMediaReferences ?: arrayListOf())
val contentRefs = (metadata?.getObjectFieldOrNull("mContentReferences") as? ArrayList<*>)?.toCollection(ArrayList())
val encryptionRefs = (metadata?.getObjectFieldOrNull("mRemoteMediaEncryption") as? ArrayList<*>)?.toCollection(ArrayList())
partContent.content = ProtoEditor(partContent.content!!).apply {
edit(3) {
remove(3)
addBuffer(3, mediaBuffers[partIndex])
}
}.toByteArray()
if (partIndex < refs.size) {
partContent.localMediaReferences = arrayListOf(refs[partIndex])
}
metadata?.let {
if (contentRefs != null && partIndex < contentRefs.size) {
it.setObjectField("mContentReferences", arrayListOf(contentRefs[partIndex]))
}
if (encryptionRefs != null && partIndex < encryptionRefs.size) {
it.setObjectField("mRemoteMediaEncryption", arrayListOf(encryptionRefs[partIndex]))
}
}
return partContent
}
fun sendPart(partIndex: Int) {
postSavePolicy = null
val partContent = buildPartMessageContent(partIndex)
val partReader = ProtoReader(partContent.content ?: return)
if (!applyOverride(partContent, partReader, overrideType, snapDurationMs)) return
val callback = if (partIndex == mediaCount - 1) {
completionCallback
} else {
CallbackBuilder(sendMessageCallbackClass)
.override("onSuccess") {
sendPart(partIndex + 1)
}
.override("onError", shouldUnhook = false) {
invokeCallbackError(completionCallback, it.argNullable<Any>(0))
}
.build()
}
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" }
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()
}
}
}
sendEvent.addCallbackResult("onError") {
MediaFilePicker.clearQueuedSplitItems()
clearQueuedOriginalItemRepeats()
}
}
if (resolvedOverrideType != null) {
if (MediaFilePicker.hasPendingSplitCleanup() || MediaFilePicker.getQueuedOverrideType() != null || queuedOriginalItemRepeatCount > 0) {
attachQueuedRepeatCallbacks(event)
}
if (sendMedia(resolvedOverrideType, 10000)) {
invokeOriginalAndRestoreResult(event)
if (event.canceled) invokeOriginalAndRestoreResult(event)
}
return@subscribe
}
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 {
@@ -649,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,
@@ -713,6 +1022,21 @@ class SendOverride : Feature("Send Override") {
fun toggleSaveable() {
selectedType = if (selectedType == "SAVEABLE_SNAP") "SNAP" else "SAVEABLE_SNAP"
}
Row(
modifier = Modifier.fillMaxWidth().clickable {
disableSplitForCurrentSend = !disableSplitForCurrentSend
},
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = disableSplitForCurrentSend,
onCheckedChange = {
disableSplitForCurrentSend = it
}
)
Text(text = mainTranslation["single_send_hint"], lineHeight = 15.sp)
}
Row(
modifier = Modifier.fillMaxWidth().clickable {
toggleSaveable()
@@ -749,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,
@@ -913,8 +1273,46 @@ 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()) {
MediaFilePicker.setQueuedOverrideType(null)
}
return@Button
} else if (MediaFilePicker.hasPendingSplitCleanup()) {
MediaFilePicker.setQueuedOverrideType(finalSelectedType)
event.addCallbackResult("onSuccess") {
context.runOnUiThread {
if (!MediaFilePicker.handleCurrentQueuedItemSuccess()) {
MediaFilePicker.clearQueuedSplitItems()
}
}
}
event.addCallbackResult("onError") {
MediaFilePicker.clearQueuedSplitItems()
}
}
val delayMs = scheduledTime?.let { it - System.currentTimeMillis() }
if (delayMs != null && delayMs > 0) {
val taskHash = java.util.UUID.randomUUID().toString()
@@ -960,8 +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)) {
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,
@@ -1008,8 +1409,24 @@ class SendOverride : Feature("Send Override") {
}
}
} else {
if (sendMedia(finalSelectedType, if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) {
invokeOriginalAndRestoreResult(event)
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.0
APP_VERSION_CODE=292
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