This commit is contained in:
ΞTΞRNAL
2026-03-26 21:24:28 +05:30
parent 95f98221e3
commit 070a8ffaf7
8 changed files with 302 additions and 27 deletions

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.4").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("300").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,9 @@
## 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

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

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

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

@@ -7,8 +7,8 @@ org.gradle.configuration-cache=true
org.gradle.configuration-cache.problems=warn
nativeAbis=arm64-v8a
APP_VERSION_NAME=1.5.4
APP_VERSION_CODE=300
APP_VERSION_NAME=1.5.5
APP_VERSION_CODE=302
debug_build_hash=18fe2a814d0e2eb5
psIntegrityPinnedSha256=
EXPECTED_CERT_SHA256=0f188cb17d8ea4902ee15ce98f4928ba4226a4790fa3c52f62d776b08e46ca1c

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;