3 Commits

Author SHA1 Message Date
ΞTΞRNAL
070a8ffaf7 v1.5.5 2026-03-26 21:24:28 +05:30
Ξ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
21 changed files with 776 additions and 64 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.2").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("296").get().toInt())
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.5.5").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("302").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,20 @@
## v1.5.5
- Fix: Custom Emoji now works for all devices!
- New: Story preview in story batch download dialog
- Fix: Auto Skip Stories getting stuck
- Fix: Stories ending up saving in .dat format
## 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

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",
@@ -3437,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",
@@ -3455,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

@@ -52,6 +52,39 @@ enum class FileType(
return result.toString()
}
private fun looksLikeIsoBmffVideo(array: ByteArray): Boolean {
if (array.size < 12) return false
// ISO BMFF containers like MP4 expose an `ftyp` box at byte offset 4.
if (array[4] != 'f'.code.toByte() ||
array[5] != 't'.code.toByte() ||
array[6] != 'y'.code.toByte() ||
array[7] != 'p'.code.toByte()
) {
return false
}
val majorBrand = String(array, 8, 4, Charsets.US_ASCII).trim('\u0000').lowercase()
return majorBrand in setOf(
"mp41",
"mp42",
"isom",
"iso2",
"iso3",
"iso4",
"iso5",
"iso6",
"avc1",
"dash",
"mif1",
"msnv",
"3gp4",
"3gp5",
"3gp6",
"3g2a",
"3g2b"
)
}
fun fromFile(file: File): FileType {
file.inputStream().use { inputStream ->
val buffer = ByteArray(16)
@@ -64,7 +97,8 @@ enum class FileType(
val headerBytes = ByteArray(16)
System.arraycopy(array, 0, headerBytes, 0, 16)
val hex = bytesToHex(headerBytes)
return fileSignatures.entries.firstOrNull { hex.startsWith(it.key) }?.value ?: UNKNOWN
return fileSignatures.entries.firstOrNull { hex.startsWith(it.key) }?.value
?: if (looksLikeIsoBmffVideo(headerBytes)) MP4 else UNKNOWN
}
fun fromInputStream(inputStream: InputStream): FileType {

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

@@ -4,6 +4,7 @@ import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.media.MediaMetadataRetriever
import android.view.Gravity
import android.view.ViewGroup.MarginLayoutParams
import android.widget.ImageView
@@ -11,11 +12,14 @@ import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import androidx.compose.foundation.background
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
@@ -26,17 +30,27 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.eternal.purrfectsnap.common.ui.createComposeAlertDialog
import me.eternal.purrfectsnap.core.ui.PurrfectGlassCard
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette
@@ -84,8 +98,12 @@ import me.eternal.purrfectsnap.core.wrapper.impl.media.SnapCipherMode
import me.eternal.purrfectsnap.core.wrapper.impl.media.toKeyPairUrlSafe
import me.eternal.purrfectsnap.core.wrapper.impl.media.HybridEncryptionResolver
import me.eternal.purrfectsnap.core.wrapper.AbstractWrapper
import okhttp3.OkHttpClient
import okhttp3.Request
import java.nio.file.Paths
import java.util.UUID
import java.util.Collections
import java.util.IdentityHashMap
import kotlin.coroutines.suspendCoroutine
import kotlin.math.absoluteValue
import android.util.Base64
@@ -107,6 +125,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
private var lastSeenMediaInfoMap: MutableMap<SplitMediaAssetType, MediaInfo>? = null
var lastSeenMapParams: ParamMap? = null
private set
private val storyPreviewCache = mutableMapOf<String, MutableMap<Int, Bitmap>>()
@Volatile
private var pendingBatchDownloadIndices: MutableList<Int>? = null
@Volatile
@@ -248,14 +267,67 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
val tr = context.translation.getCategory("download_processor.story_snap_dialog")
val cancelStr = context.translation["button.cancel"]
val downloadStr = context.translation["button.download"]
val previewCacheKey = buildString {
append(paramMap["STORY_ID"]?.toString() ?: "story")
append("|")
append(paramMap["TOPIC_SNAP_CREATOR_USER_ID"]?.toString() ?: "user")
append("|")
append(totalCount)
}
context.runOnUiThread {
createComposeAlertDialog(context.mainActivity!!) { alertDialog ->
PurrfectOverlayTheme {
val selected = remember { mutableStateListOf<Int>().apply { add(currentIndex) } }
val previewBitmaps = remember { mutableStateMapOf<Int, Bitmap?>() }
val previewLoading = remember { mutableStateMapOf<Int, Boolean>() }
LaunchedEffect(Unit) {
if (!selected.contains(currentIndex)) selected.add(currentIndex)
mediaInfoMap[SplitMediaAssetType.ORIGINAL]?.uri?.let { currentUri ->
previewLoading[currentIndex] = true
previewBitmaps[currentIndex] = withContext(Dispatchers.IO) { loadStoryPreviewBitmap(currentUri) }
previewLoading[currentIndex] = false
}
synchronized(storyPreviewCache) {
storyPreviewCache[previewCacheKey]?.forEach { (index, bitmap) ->
previewBitmaps[index] = bitmap
}
}
}
LaunchedEffect(previewCacheKey) {
val overlay = context.feature(OperaStoryOverlay::class)
val cachedIndices = synchronized(storyPreviewCache) {
storyPreviewCache.getOrPut(previewCacheKey) { mutableMapOf() }.keys.toSet()
}
val indicesToScan = (0 until totalCount).filter { it != currentIndex && it !in cachedIndices }
try {
for (targetIndex in indicesToScan) {
val jumped = withContext(Dispatchers.Main) {
overlay.requestJumpToSnap(targetIndex, totalCount)
}
if (!jumped) continue
val reached = waitForStoryIndex(targetIndex)
if (!reached) continue
val uri = lastSeenMediaInfoMap?.get(SplitMediaAssetType.ORIGINAL)?.uri ?: continue
previewLoading[targetIndex] = true
val bitmap = withContext(Dispatchers.IO) { loadStoryPreviewBitmap(uri) }
previewLoading[targetIndex] = false
if (bitmap != null) {
previewBitmaps[targetIndex] = bitmap
synchronized(storyPreviewCache) {
storyPreviewCache.getOrPut(previewCacheKey) { mutableMapOf() }[targetIndex] = bitmap
}
}
}
} finally {
withContext(Dispatchers.Main) {
overlay.requestJumpToSnap(currentIndex, totalCount)
}
}
}
PurrfectGlassCard(
@@ -280,7 +352,8 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Checkbox(
checked = selected.contains(index),
@@ -289,6 +362,35 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
},
colors = CheckboxDefaults.colors(checkedColor = PurrfectOverlayPalette.glowPrimary)
)
Box(
modifier = Modifier
.size(54.dp)
.background(Color.White.copy(alpha = 0.06f), RoundedCornerShape(12.dp)),
contentAlignment = Alignment.Center
) {
val rowBitmap = previewBitmaps[index]
val rowLoading = previewLoading[index] == true
when {
rowBitmap != null -> Image(
bitmap = rowBitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier
.size(54.dp)
.background(Color.Transparent, RoundedCornerShape(12.dp)),
contentScale = ContentScale.Crop
)
rowLoading -> CircularProgressIndicator(
color = PurrfectOverlayPalette.glowPrimary,
modifier = Modifier.size(22.dp),
strokeWidth = 2.dp
)
else -> Icon(
imageVector = Icons.Outlined.Image,
contentDescription = null,
tint = PurrfectOverlayPalette.textSecondary
)
}
}
Text(
label,
style = MaterialTheme.typography.bodyMedium,
@@ -355,6 +457,49 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
}
}
private suspend fun waitForStoryIndex(targetIndex: Int, timeoutMs: Long = 3000L): Boolean {
val startedAt = System.currentTimeMillis()
while (System.currentTimeMillis() - startedAt < timeoutMs) {
if (lastSeenMapParams?.getStorySnapIndex() == targetIndex) return true
kotlinx.coroutines.delay(60L)
}
return false
}
private fun loadStoryPreviewBitmap(uriString: String): Bitmap? {
return runCatching {
val uri = Uri.parse(uriString)
when (uri.scheme?.lowercase()) {
"content" -> context.androidContext.contentResolver.openInputStream(uri)?.use(BitmapFactory::decodeStream)
"file", null -> BitmapFactory.decodeFile(uri.path)
"http", "https" -> {
runCatching {
OkHttpClient().newCall(Request.Builder().url(uriString).build()).execute().use { response ->
response.body?.byteStream()?.use { stream -> BitmapFactory.decodeStream(stream) }
}
}.getOrNull() ?: run {
val retriever = MediaMetadataRetriever()
try {
retriever.setDataSource(uriString, emptyMap())
retriever.frameAtTime
} finally {
runCatching { retriever.release() }
}
}
}
else -> null
} ?: run {
val retriever = MediaMetadataRetriever()
try {
retriever.setDataSource(context.androidContext, uri)
retriever.frameAtTime
} finally {
runCatching { retriever.release() }
}
}
}.getOrNull()
}
private fun startBatchDownload(indices: MutableList<Int>, allowDuplicate: Boolean) {
if (indices.isEmpty()) return
val paramMap = lastSeenMapParams ?: return

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

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

View File

@@ -22,6 +22,7 @@ class OperaStoryOverlayState {
val totalCountState = mutableIntStateOf(0)
val snapSourceState = mutableStateOf<String?>(null)
val isInConversationState = mutableStateOf(false)
val storyIdentityState = mutableStateOf<String?>(null)
fun setupDisplayStateHook(
context: ModContext,
@@ -63,6 +64,14 @@ class OperaStoryOverlayState {
?: mediaParamMap["SNAP_POSITION_IN_STORY"]?.toString()?.toIntOrNull()
val totalCount = mediaParamMap["snap_story_length"]?.toString()?.toIntOrNull()
?: mediaParamMap["NUM_SNAPS_IN_STORY"]?.toString()?.toIntOrNull()
val storyIdentity = mediaParamMap["STORY_ID"]?.toString()
?.takeIf { it.isNotBlank() && it != "null" }
?: mediaParamMap["TOPIC_SNAP_CREATOR_USER_ID"]?.toString()
?.takeIf { it.isNotBlank() && it != "null" }
?: mediaParamMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString()
?.substringAfter("storyUserId=", "")
?.substringBefore(",")
?.takeIf { it.isNotBlank() && it != "null" }
var mediaOrigin = ""
if (showSourceIndicator) {
@@ -81,6 +90,7 @@ class OperaStoryOverlayState {
totalCountState.intValue = totalCount ?: 0
snapSourceState.value = snapSource
isInConversationState.value = false
storyIdentityState.value = storyIdentity
onSnapFullyDisplayed?.let { callback ->
if (currentIndex != null) callback(currentIndex)

View File

@@ -26,6 +26,8 @@ class OperaStorySnapJump(
private var retryRunnable: Runnable? = null
private var nextTapRunnable: Runnable? = null
private var lastHandledIndex = -1
private var jumpOriginStoryIdentity: String? = null
private var jumpOriginTotalCount: Int = 0
fun simulateTap(forward: Boolean) {
val activity = context.mainActivity ?: return
@@ -83,6 +85,8 @@ class OperaStorySnapJump(
isJumping = false
jumpTargetIndex = -1
lastHandledIndex = -1
jumpOriginStoryIdentity = null
jumpOriginTotalCount = 0
mainHandler.postDelayed({
val overlay = storyFrameLayout()?.findViewWithTag<View>("jump_overlay") ?: return@postDelayed
overlay.animate()
@@ -99,6 +103,14 @@ class OperaStorySnapJump(
removeJumpOverlay()
return
}
if (jumpOriginStoryIdentity != null && overlayState.storyIdentityState.value != null && jumpOriginStoryIdentity != overlayState.storyIdentityState.value) {
removeJumpOverlay()
return
}
if (jumpOriginTotalCount > 0 && overlayState.totalCountState.intValue > 0 && jumpOriginTotalCount != overlayState.totalCountState.intValue) {
removeJumpOverlay()
return
}
val forward = jumpTargetIndex > fromIndex
simulateTap(forward)
@@ -114,6 +126,14 @@ class OperaStorySnapJump(
val retry = Runnable {
if (!isJumping || gen != jumpGeneration) return@Runnable
val currentIdx = overlayState.currentIndexState.intValue
if (jumpOriginStoryIdentity != null && overlayState.storyIdentityState.value != null && jumpOriginStoryIdentity != overlayState.storyIdentityState.value) {
removeJumpOverlay()
return@Runnable
}
if (jumpOriginTotalCount > 0 && overlayState.totalCountState.intValue > 0 && jumpOriginTotalCount != overlayState.totalCountState.intValue) {
removeJumpOverlay()
return@Runnable
}
if (currentIdx == fromIndex) {
if (retryCount >= maxRetries) {
removeJumpOverlay()
@@ -129,6 +149,14 @@ class OperaStorySnapJump(
fun onSnapFullyDisplayed(currentIndex: Int) {
if (!isJumping || jumpTargetIndex < 0) return
if (currentIndex == lastHandledIndex) return
if (jumpOriginStoryIdentity != null && overlayState.storyIdentityState.value != null && jumpOriginStoryIdentity != overlayState.storyIdentityState.value) {
removeJumpOverlay()
return
}
if (jumpOriginTotalCount > 0 && overlayState.totalCountState.intValue > 0 && jumpOriginTotalCount != overlayState.totalCountState.intValue) {
removeJumpOverlay()
return
}
cancelPendingRetry()
cancelPendingNextTap()
@@ -138,6 +166,18 @@ class OperaStorySnapJump(
return
}
if (lastHandledIndex >= 0) {
val expectedForward = jumpTargetIndex > lastHandledIndex
if (expectedForward && currentIndex < lastHandledIndex) {
removeJumpOverlay()
return
}
if (!expectedForward && currentIndex > lastHandledIndex) {
removeJumpOverlay()
return
}
}
lastHandledIndex = currentIndex
val gen = jumpGeneration
val tapRunnable = Runnable {
@@ -179,6 +219,8 @@ class OperaStorySnapJump(
jumpTargetIndex = targetIndex
lastHandledIndex = -1
isJumping = true
jumpOriginStoryIdentity = overlayState.storyIdentityState.value
jumpOriginTotalCount = overlayState.totalCountState.intValue
showJumpOverlay()

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.2
APP_VERSION_CODE=296
APP_VERSION_NAME=1.5.5
APP_VERSION_CODE=302
debug_build_hash=18fe2a814d0e2eb5
psIntegrityPinnedSha256=
EXPECTED_CERT_SHA256=0f188cb17d8ea4902ee15ce98f4928ba4226a4790fa3c52f62d776b08e46ca1c
android.disallowKotlinSourceSets=false
android.sourceset.disallowProvider=false
ksp.incremental=false

View File

@@ -1,31 +1,70 @@
use std::{ffi::{CStr, CString}, fs};
use std::{cell::Cell, ffi::{CStr, CString}};
use nix::libc::{self, c_uint};
use crate::{config, def_hook, dobby_hook_sym};
thread_local! {
static FONT_REDIRECT_IN_PROGRESS: Cell<bool> = const { Cell::new(false) };
}
fn should_redirect_font(pathname: &str) -> bool {
let normalized = pathname.replace('\\', "/");
let file_name = normalized.rsplit('/').next().unwrap_or(&normalized).to_ascii_lowercase();
let is_font_file = file_name.ends_with(".ttf")
|| file_name.ends_with(".ttc")
|| file_name.ends_with(".otf");
let is_system_font_path = normalized.starts_with("/system/fonts/")
|| normalized.starts_with("/product/fonts/")
|| normalized.starts_with("/system_ext/fonts/")
|| normalized.starts_with("/vendor/fonts/");
is_system_font_path && is_font_file && (
file_name.contains("emoji")
|| file_name == "noto_color_emoji.ttf"
|| file_name == "samsungcoloremoji.ttf"
)
}
fn open_custom_font_fd(flags: i32, mode: c_uint) -> Option<i32> {
let font_path = config::native_config().custom_emoji_font_path.clone()?;
match CString::new(font_path.clone()) {
Ok(c_font_path) => {
let fd = FONT_REDIRECT_IN_PROGRESS.with(|guard| {
let was_active = guard.replace(true);
let fd = unsafe { libc::openat(libc::AT_FDCWD, c_font_path.as_ptr() as *const u8, flags, mode) };
guard.set(was_active);
fd
});
if fd >= 0 {
debug!("redirected emoji font open to {}", font_path);
Some(fd)
} else {
debug!("failed to open custom emoji font path (fd={}): {}", fd, font_path);
None
}
}
Err(_) => {
warn!("custom emoji font path contains null byte, using fallback system font");
None
}
}
}
def_hook!(
open_hook,
i32,
|path: *const u8, flags: i32, mode: c_uint| {
if let Ok(pathname) = CStr::from_ptr(path).to_str() {
if pathname == "/system/fonts/NotoColorEmoji.ttf" {
if let Some(font_path) = config::native_config().custom_emoji_font_path {
if fs::metadata(&font_path).is_ok() {
match CString::new(font_path.clone()) {
Ok(c_font_path) => {
let fd = libc::openat(libc::AT_FDCWD, c_font_path.as_ptr() as *const u8, flags, mode);
if fd >= 0 {
return fd;
}
warn!("failed to open custom emoji font path (fd={}): {}", fd, font_path);
}
Err(_) => {
warn!("custom emoji font path contains null byte, using fallback system font");
}
}
} else {
warn!("custom emoji font path does not exist: {}", font_path);
if FONT_REDIRECT_IN_PROGRESS.with(|guard| guard.get()) {
return open_hook_original.unwrap()(path, flags, mode);
}
if !path.is_null() {
if let Ok(pathname) = CStr::from_ptr(path).to_str() {
if should_redirect_font(pathname) {
if let Some(fd) = open_custom_font_fd(flags, mode) {
return fd;
}
}
}
@@ -35,7 +74,6 @@ def_hook!(
}
);
pub fn init() {
if config::native_config().custom_emoji_font_path.is_none() {
return;