diff --git a/build.gradle.kts b/build.gradle.kts index 0bdb297c..59829c76 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -33,8 +33,8 @@ tasks.register("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. diff --git a/changelogs-stable.txt b/changelogs-stable.txt index cbe5a802..dd716405 100644 --- a/changelogs-stable.txt +++ b/changelogs-stable.txt @@ -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 diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/data/FileType.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/data/FileType.kt index 81a82180..8f21f8fb 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/data/FileType.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/data/FileType.kt @@ -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 { diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt index 0f2473de..486c4646 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt @@ -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? = null var lastSeenMapParams: ParamMap? = null private set + private val storyPreviewCache = mutableMapOf>() @Volatile private var pendingBatchDownloadIndices: MutableList? = 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().apply { add(currentIndex) } } + val previewBitmaps = remember { mutableStateMapOf() } + val previewLoading = remember { mutableStateMapOf() } 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, allowDuplicate: Boolean) { if (indices.isEmpty()) return val paramMap = lastSeenMapParams ?: return diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryOverlayState.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryOverlayState.kt index 6f666c3c..a0895336 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryOverlayState.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryOverlayState.kt @@ -22,6 +22,7 @@ class OperaStoryOverlayState { val totalCountState = mutableIntStateOf(0) val snapSourceState = mutableStateOf(null) val isInConversationState = mutableStateOf(false) + val storyIdentityState = mutableStateOf(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) diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStorySnapJump.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStorySnapJump.kt index eed81245..d730f9c3 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStorySnapJump.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStorySnapJump.kt @@ -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("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() diff --git a/gradle.properties b/gradle.properties index 2f0749fc..916bb56d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 diff --git a/native/rust/src/modules/custom_font_hook.rs b/native/rust/src/modules/custom_font_hook.rs index c97ec6ee..f76d3f9a 100644 --- a/native/rust/src/modules/custom_font_hook.rs +++ b/native/rust/src/modules/custom_font_hook.rs @@ -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 = 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 { + 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;