many changes

new feature to record both sides while call recording(upstream commit), fix armv7 failed to initialize error & fix a9 issues
This commit is contained in:
particle-box
2026-01-19 01:52:26 +05:30
parent 1ba7e411ca
commit 9fa733f98d
27 changed files with 812 additions and 319 deletions

View File

@@ -4,6 +4,7 @@ import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.res.Resources
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.Process
@@ -161,7 +162,8 @@ class ModContext(
NativeConfig(
disableBitmoji = config.experimental.nativeHooks.disableBitmoji.get(),
disableMetrics = config.global.disableMetrics.get(),
valdiHooks = config.experimental.nativeHooks.valdiHooks.globalState == true,
valdiHooks = config.experimental.nativeHooks.valdiHooks.globalState == true &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q,
customEmojiFontPath = getCustomEmojiFontPath(this)
)
)

View File

@@ -7,6 +7,7 @@ import android.content.res.Resources
import android.os.Build
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Cancel
import java.lang.reflect.Method
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -49,6 +50,8 @@ class PurrfectSnap {
}
private lateinit var appContext: ModContext
private var isBridgeInitialized = false
private var android9ValdiBindDisabled = false
private var android9ValdiBindDisableLogged = false
private fun hookMainActivity(methodName: String, stage: HookStage = HookStage.AFTER, block: Activity.(param: HookAdapter) -> Unit) {
Activity::class.java.hook(methodName, stage, { isBridgeInitialized }) { param ->
@@ -156,6 +159,7 @@ class PurrfectSnap {
}
reloadConfig()
installAndroid9ValdiBindGuard()
initNative()
initWidgetListener()
scope.launch(Dispatchers.IO) {
@@ -244,6 +248,89 @@ class PurrfectSnap {
}
}
private fun installAndroid9ValdiBindGuard() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) return
val valdiNativeBridge = runCatching {
classLoader.loadClass("com.snapchat.client.valdi.NativeBridge")
}.getOrNull()
val targetClasses = listOf("AXj", "NZj", "MZj")
.mapNotNull { className ->
runCatching { classLoader.loadClass(className) }.getOrNull()
}
.distinct()
if (targetClasses.isEmpty()) return
fun defaultReturn(type: Class<*>): Any? = when (type) {
Boolean::class.javaPrimitiveType -> false
Byte::class.javaPrimitiveType -> 0.toByte()
Short::class.javaPrimitiveType -> 0.toShort()
Int::class.javaPrimitiveType -> 0
Long::class.javaPrimitiveType -> 0L
Float::class.javaPrimitiveType -> 0f
Double::class.javaPrimitiveType -> 0.0
Char::class.javaPrimitiveType -> 0.toChar()
Void.TYPE -> null
else -> null
}
fun installGuard(targetClass: Class<*>, methodName: String) {
targetClass.hook(methodName, HookStage.BEFORE) { param ->
val method = param.method() as? Method ?: return@hook
if (android9ValdiBindDisabled) {
if (!android9ValdiBindDisableLogged) {
android9ValdiBindDisableLogged = true
appContext.log.warn("Skipping Valdi bind on Android 9 due to missing native impl")
}
param.setResult(defaultReturn(method.returnType))
return@hook
}
runCatching {
param.invokeOriginal()
}.onSuccess { result ->
param.setResult(result)
}.onFailure { throwable ->
val rootCause = throwable.cause ?: throwable
val message = rootCause.message ?: throwable.message
val isMissingNativeImpl = rootCause is UnsatisfiedLinkError &&
message?.contains("NativeBridge.createContext") == true
val isValdiContextNpe = rootCause is NullPointerException &&
message?.contains("ValdiContext") == true
if (isMissingNativeImpl || isValdiContextNpe) {
android9ValdiBindDisabled = true
if (!android9ValdiBindDisableLogged) {
android9ValdiBindDisableLogged = true
appContext.log.warn("Skipping Valdi bind on Android 9 due to missing native impl")
if (isMissingNativeImpl) {
appContext.log.error("Android 9 Valdi NativeBridge.createContext missing native impl", rootCause)
}
}
param.setResult(defaultReturn(method.returnType))
return@hook
}
appContext.log.error("Android 9 Valdi bind hook failed", throwable)
param.setResult(defaultReturn(method.returnType))
}
}
}
targetClasses.forEach { targetClass ->
installGuard(targetClass, "f")
installGuard(targetClass, "g2")
installGuard(targetClass, "n2")
installGuard(targetClass, "d")
}
valdiNativeBridge?.hook("createContext", HookStage.AFTER) { param ->
val throwable = param.throwable() as? UnsatisfiedLinkError ?: return@hook
android9ValdiBindDisabled = true
if (!android9ValdiBindDisableLogged) {
android9ValdiBindDisableLogged = true
appContext.log.warn("Skipping Valdi bind on Android 9 due to missing native impl")
appContext.log.error("Android 9 Valdi NativeBridge.createContext missing native impl", throwable)
}
param.setResult(null)
}
}
private fun initNative() {
val nativeSigCacheFileHandle = appContext.fileHandlerManager.getFileHandle(FileHandleScope.INTERNAL.key, InternalFileHandleType.NATIVE_SIG_CACHE.key).toWrapper()

View File

@@ -14,6 +14,7 @@ import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withTimeoutOrNull
import me.eternal.purrfectsnap.bridge.*
import me.eternal.purrfectsnap.bridge.call.CallDownloadSession
import me.eternal.purrfectsnap.bridge.e2ee.E2eeInterface
import me.eternal.purrfectsnap.bridge.location.LocationManager
import me.eternal.purrfectsnap.bridge.logger.LoggerInterface
@@ -284,5 +285,12 @@ class BridgeClient(
fun registerConfigStateListener(listener: ConfigStateListener) = safeServiceCall { service.registerConfigStateListener(listener) }
fun getDebugProp(name: String, defaultValue: String? = null): String? = safeServiceCall { service.getDebugProp(name, defaultValue) }
fun startCallDownload(
startTimestamp: Long,
author: String,
): CallDownloadSession {
return safeServiceCall { service.startCallDownload(startTimestamp, author) }
}
}

View File

@@ -5,6 +5,7 @@ import android.content.Intent
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.os.Build
import me.eternal.purrfectsnap.common.util.snap.SnapWidgetBroadcastReceiverHelper
import me.eternal.purrfectsnap.core.ModContext
import me.eternal.purrfectsnap.core.event.events.impl.*
@@ -27,6 +28,10 @@ class EventDispatcher(
private val context: ModContext
) {
private fun hookViewBinder() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
context.log.warn("BindViewEvent hooks disabled on Android 9 and below")
return
}
context.mappings.useMapper(ViewBinderMapper::class) {
val cachedHooks = mutableListOf<String>()
fun cacheHook(clazz: Class<*>, block: Class<*>.() -> Unit) {

View File

@@ -6,6 +6,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import me.eternal.purrfectsnap.core.ModContext
import me.eternal.purrfectsnap.core.features.impl.*
import me.eternal.purrfectsnap.core.features.impl.downloader.CallRecorder
import me.eternal.purrfectsnap.core.features.impl.downloader.MediaDownloader
import me.eternal.purrfectsnap.core.features.impl.downloader.ProfilePictureDownloader
import me.eternal.purrfectsnap.core.features.impl.experiments.*

View File

@@ -0,0 +1,178 @@
package me.eternal.purrfectsnap.core.features.impl.downloader
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.AudioTrack
import android.os.ParcelFileDescriptor
import me.eternal.purrfectsnap.bridge.call.CallDownloadSession
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.util.hook.hookConstructor
import me.eternal.purrfectsnap.core.util.ktx.getObjectField
import me.eternal.purrfectsnap.core.util.ktx.getObjectFieldOrNull
import me.eternal.purrfectsnap.core.wrapper.impl.SnapUUID
import java.io.OutputStream
import java.nio.ByteBuffer
import java.util.concurrent.ConcurrentHashMap
class CallRecorder : Feature("Call Recorder") {
private var wasInCall = false
private var callDownloadSession: CallDownloadSession? = null
inner class LazyStream(
private val audioFormat: AudioFormat,
private val startTimestamp: Long = System.currentTimeMillis(),
) {
private var stream: ParcelFileDescriptor.AutoCloseOutputStream? = null
fun get(): OutputStream? {
if (stream != null) return stream
if (callDownloadSession == null) return null
stream = ParcelFileDescriptor.AutoCloseOutputStream(
callDownloadSession?.createStream(
startTimestamp,
audioFormat.channelCount,
audioFormat.sampleRate,
audioFormat.encoding
) ?: return null
)
return stream
}
}
private fun initCallDownloadSession(conversationId: String) {
val author = (if (context.database.getConversationType(conversationId) == 1) {
context.database.getFeedEntryByConversationId(conversationId)?.feedDisplayName
} else {
context.database.getDMOtherParticipant(conversationId)?.let { context.database.getFriendInfo(it)?.mutableUsername }
}) ?: "unknown"
callDownloadSession = context.bridgeClient.startCallDownload(System.currentTimeMillis(), author)
}
private fun onCallStarted(conversationId: String) {
initCallDownloadSession(conversationId)
}
private fun onCallEnded(conversationId: String) {
callDownloadSession?.end()
}
override fun init() {
val callRecorderConfig = context.config.downloader.callRecorder.getNullable()
if (callRecorderConfig == null) return
val streams = ConcurrentHashMap<Int, LazyStream>() // audioTrack -> stream
runCatching {
findClass("com.snapchat.talkcorev3.CallingSessionState")
}.getOrNull()?.hookConstructor(HookStage.AFTER) { param ->
val instance = param.thisObject<Any>()
val callingState = instance.getObjectFieldOrNull("mLocalUser")?.getObjectField("mCallingState")
if (callingState.toString() == "IN_CALL") {
// TODO: implement for older Snapchat versions
}
} ?: findClass("com.snapchat.talkcorev3.TSCallingStateUpdateParams").hookConstructor(
HookStage.AFTER) { param ->
val instance = param.thisObject<Any>()
val conversationId = SnapUUID(instance.getObjectField("mConversationId")).toString()
if (instance.getObjectFieldOrNull("mInCall") == true) {
if (!wasInCall) {
wasInCall = true
onCallStarted(conversationId)
}
} else {
if (wasInCall) {
wasInCall = false
onCallEnded(conversationId)
}
}
}
AudioRecord::class.java.apply {
if (callRecorderConfig == "only_record_others") return@apply
declaredConstructors.first { it.parameterCount > 5 }.hook(HookStage.AFTER) { param ->
val audioAttributes = param.arg<AudioAttributes>(0)
context.log.verbose(audioAttributes.usage)
if (audioAttributes.usage != AudioAttributes.USAGE_UNKNOWN) return@hook
val audioFormat = param.arg<AudioFormat>(1)
val hashCode = param.thisObject<Any>().hashCode()
streams.put(hashCode, LazyStream(audioFormat))
context.log.verbose("AudioRecord called usage=${audioAttributes.usage}, format=$audioFormat")
}
getMethod("read", ByteBuffer::class.java, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType).hook(
HookStage.AFTER) { param ->
val readBytes = param.getResult() as Int
if (readBytes <= 0) return@hook
streams[param.thisObject<Any>().hashCode()]?.let { handlers ->
val byteBuffer = param.arg<ByteBuffer>(0)
val position = byteBuffer.position()
val buffer = ByteArray(readBytes)
byteBuffer.get(buffer)
byteBuffer.position(position)
runCatching {
handlers.get()?.write(buffer, 0, buffer.size)
}.onFailure {
context.log.error("Failed to record call audio data", it)
}
}
}
hook("release", HookStage.BEFORE) {
runCatching {
streams.remove(it.thisObject<Any>().hashCode())?.get()?.close()
}
}
}
AudioTrack::class.java.apply {
if (callRecorderConfig == "only_record_self") return@apply
getConstructor(
AudioAttributes::class.java,
AudioFormat::class.java,
Int::class.javaPrimitiveType,
Int::class.javaPrimitiveType,
Int::class.javaPrimitiveType,
).hook(HookStage.AFTER) { param ->
val audioAttributes = param.arg<AudioAttributes>(0)
if (audioAttributes.usage != AudioAttributes.USAGE_VOICE_COMMUNICATION) return@hook
val audioFormat = param.arg<AudioFormat>(1)
val hashCode = param.thisObject<Any>().hashCode()
streams.put(hashCode, LazyStream(audioFormat))
context.log.verbose("AudioTrack called usage=${audioAttributes.usage}, format=$audioFormat")
}
getMethod("write", ByteBuffer::class.java, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType).hook(
HookStage.BEFORE) { param ->
streams[param.thisObject<Any>().hashCode()]?.let { handlers ->
val byteBuffer = param.arg<ByteBuffer>(0)
val position = byteBuffer.position()
val buffer = ByteArray(param.arg(1))
byteBuffer.get(buffer)
byteBuffer.position(position)
runCatching {
handlers.get()?.write(buffer, 0, buffer.size)
}.onFailure {
context.log.error("Failed to record call audio data", it)
}
}
}
hook("release", HookStage.BEFORE) {
runCatching { streams.remove(it.thisObject<Any>().hashCode())?.get()?.close() }
}
}
}
}

View File

@@ -6,6 +6,7 @@ import android.widget.RelativeLayout
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent
import me.eternal.purrfectsnap.core.event.events.impl.NetworkApiRequestEvent
import me.eternal.purrfectsnap.core.event.events.impl.UnaryCallEvent
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.ui.ViewAppearanceHelper
@@ -19,11 +20,23 @@ class ProfilePictureDownloader : Feature("ProfilePictureDownloader") {
var avatarUrl: String? = null
onNextActivityCreate(defer = true) {
val profileViewClasses = setOf(
"com.snap.unifiedpublicprofile.UnifiedPublicProfileView",
"com.snap.modules.profile3.UserProfileV2RootComponent",
"com.snap.profile.ui.flatland.UnifiedProfileFlatlandProfileView"
)
context.event.subscribe(AddViewEvent::class) { event ->
if (event.view::class.java.name != "com.snap.unifiedpublicprofile.UnifiedPublicProfileView") return@subscribe
if (event.view::class.java.name !in profileViewClasses) return@subscribe
val buttonText = this@ProfilePictureDownloader.context.translation["profile_picture_downloader.button"]
if ((0 until event.parent.childCount).any {
val child = event.parent.getChildAt(it)
child is Button && (child as Button).text == buttonText
}) return@subscribe
event.parent.addView(Button(event.parent.context).apply {
text = this@ProfilePictureDownloader.context.translation["profile_picture_downloader.button"]
text = buttonText
layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT).apply {
setMargins(0, 200, 0, 0)
}
@@ -36,16 +49,21 @@ class ProfilePictureDownloader : Feature("ProfilePictureDownloader") {
backgroundUrl?.let { choices["background_option"] = it }
avatarUrl?.let { choices["avatar_option"] = it }
setItems(choices.keys.map {
this@ProfilePictureDownloader.context.translation["profile_picture_downloader.$it"]
}.toTypedArray()) { _, which ->
runCatching {
this@ProfilePictureDownloader.context.feature(MediaDownloader::class).downloadProfilePicture(
choices.values.elementAt(which),
friendUsername!!
)
}.onFailure {
this@ProfilePictureDownloader.context.log.error("Failed to download profile picture", it)
if (choices.isEmpty()) {
setMessage("No profile pictures available. Please wait for the profile to load.")
setPositiveButton("OK") { dialog, _ -> dialog.dismiss() }
} else {
setItems(choices.keys.map {
this@ProfilePictureDownloader.context.translation["profile_picture_downloader.$it"]
}.toTypedArray()) { _, which ->
runCatching {
this@ProfilePictureDownloader.context.feature(MediaDownloader::class).downloadProfilePicture(
choices.values.elementAt(which),
friendUsername ?: "unknown"
)
}.onFailure {
this@ProfilePictureDownloader.context.log.error("Failed to download profile picture", it)
}
}
}
}.show()
@@ -54,18 +72,31 @@ class ProfilePictureDownloader : Feature("ProfilePictureDownloader") {
}
context.event.subscribe(NetworkApiRequestEvent::class) { event ->
if (!event.url.endsWith("/rpc/getPublicProfile")) return@subscribe
event.onSuccess { buffer ->
ProtoReader(buffer ?: return@onSuccess).followPath(1, 1, 2) {
fun parseProfileData(buffer: ByteArray) {
runCatching {
ProtoReader(buffer).followPath(1, 1, 2) {
friendUsername = getString(2) ?: return@followPath
followPath(4) {
backgroundUrl = getString(2)
avatarUrl = getString(100)
}
}
}.onFailure {
context.log.error("Failed to parse profile picture data", it)
}
}
context.event.subscribe(NetworkApiRequestEvent::class) { event ->
if (!event.url.contains("getPublicProfile")) return@subscribe
event.onSuccess { buffer -> buffer?.let { parseProfileData(it) } }
}
context.event.subscribe(UnaryCallEvent::class) { event ->
if (!event.uri.contains("getPublicProfile")) return@subscribe
event.addResponseCallback {
parseProfileData(buffer)
}
}
}
}
}
}

View File

@@ -1,141 +0,0 @@
package me.eternal.purrfectsnap.core.features.impl.experiments
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioTrack
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import me.eternal.purrfectsnap.common.data.download.AudioStreamFormat
import me.eternal.purrfectsnap.common.data.download.MediaDownloadSource
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.features.impl.downloader.MediaDownloader
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.util.hook.hookConstructor
import me.eternal.purrfectsnap.core.util.ktx.getObjectField
import me.eternal.purrfectsnap.core.util.ktx.getObjectFieldOrNull
import me.eternal.purrfectsnap.core.util.media.HttpServer
import java.io.PipedInputStream
import java.io.PipedOutputStream
import java.nio.ByteBuffer
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
class CallRecorder : Feature("Call Recorder") {
private val httpServer = HttpServer(
timeout = Integer.MAX_VALUE
)
override fun init() {
if (!context.config.experimental.callRecorder.get()) return
val streamHandlers = ConcurrentHashMap<Int, MutableList<(data: ByteArray) -> Unit>>() // audioTrack -> handlers
val participants = CopyOnWriteArrayList<String>()
runCatching {
findClass("com.snapchat.talkcorev3.CallingSessionState")
}.getOrNull()?.hookConstructor(HookStage.AFTER) { param ->
val instance = param.thisObject<Any>()
val callingState = instance.getObjectFieldOrNull("mLocalUser")?.getObjectField("mCallingState")
if (callingState.toString() == "IN_CALL") {
participants.clear()
participants.addAll((instance.getObjectField("mParticipants") as Map<*, *>).keys.map { it.toString() })
}
} ?: findClass("com.snapchat.talkcorev3.TSCallingStateUpdateParams").hookConstructor(HookStage.AFTER) { param ->
val instance = param.thisObject<Any>()
if (instance.getObjectFieldOrNull("mInCall") == true) {
participants.clear()
participants.addAll((instance.getObjectField("mParticipants") as Set<*>).map { it.toString() })
}
}
AudioTrack::class.java.apply {
getConstructor(
AudioAttributes::class.java,
AudioFormat::class.java,
Int::class.javaPrimitiveType,
Int::class.javaPrimitiveType,
Int::class.javaPrimitiveType,
).hook(HookStage.BEFORE) { param ->
val audioAttributes = param.arg<AudioAttributes>(0)
if (audioAttributes.usage != AudioAttributes.USAGE_VOICE_COMMUNICATION) return@hook
val audioFormat = param.arg<AudioFormat>(1)
val hashCode = param.thisObject<Any>().hashCode()
lateinit var streamUrl: String
streamUrl = httpServer.ensureServerStarted()?.putContent(
object: HttpServer.HttpContent() {
override val contentType: String = "audio/wav"
override val chunked: Boolean = true
override val contentLength: Long? = null
override val newBody: () -> HttpServer.HttpBody = {
object: HttpServer.HttpBody() {
val outputStream = PipedOutputStream()
val inputStream = PipedInputStream(outputStream)
val handler: (byteArray: ByteArray) -> Unit = handler@{ byteArray ->
if (byteArray.isEmpty()) {
httpServer.removeUrl(streamUrl)
return@handler
}
runCatching {
outputStream.write(byteArray)
outputStream.flush()
}.onFailure {
context.log.warn("Failed to write to streaming url ${it.localizedMessage}")
}
}
override val onOpen: () -> Unit = {
streamHandlers.getOrPut(hashCode) { CopyOnWriteArrayList() }.add(handler)
}
override val readBytes: (byteArray: ByteArray) -> Int = { byteArray ->
runBlocking {
withTimeoutOrNull(3000L) {
inputStream.read(byteArray)
} ?: -1
}
}
override val onClose: () -> Unit = {
context.log.verbose("Streaming url closed")
streamHandlers[hashCode]?.remove(handler)
outputStream.close()
inputStream.close()
}
}
}
}
) ?: return@hook
context.log.verbose("streaming url = $streamUrl, sampleRate = ${audioFormat.sampleRate}, audioFormat = ${audioFormat.encoding}")
context.feature(MediaDownloader::class).provideDownloadManagerClient(
UUID.randomUUID().toString(),
participants.mapNotNull { context.database.getFriendInfo(it)?.mutableUsername }.joinToString("-"),
System.currentTimeMillis(),
MediaDownloadSource.VOICE_CALL
).downloadStream(streamUrl, AudioStreamFormat(audioFormat.channelCount, audioFormat.sampleRate, audioFormat.encoding))
}
getMethod("write", ByteBuffer::class.java, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType).hook(HookStage.BEFORE) { param ->
streamHandlers[param.thisObject<Any>().hashCode()]?.let { handlers ->
val byteBuffer = param.arg<ByteBuffer>(0)
val position = byteBuffer.position()
val buffer = ByteArray(param.arg(1))
byteBuffer.get(buffer)
byteBuffer.position(position)
handlers.forEach { it(buffer) }
}
}
hook("release", HookStage.BEFORE) {
streamHandlers.remove(it.thisObject<Any>().hashCode())?.forEach { it(ByteArray(0)) }
}
}
}
}

View File

@@ -17,6 +17,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import android.os.Build
import me.eternal.purrfectsnap.common.bridge.FileHandleScope
import me.eternal.purrfectsnap.common.bridge.toWrapper
import me.eternal.purrfectsnap.common.ui.createComposeAlertDialog
@@ -113,6 +114,10 @@ class ValdiHooks: Feature("ValdiHooks") {
@Suppress("UNCHECKED_CAST")
override fun init() {
if (config.globalState != true) return
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
context.log.warn("ValdiHooks disabled on Android 9 and below")
return
}
if (PurrfectSnap.classCache.valdiFunction == null) {
context.log.warn("ComposerFunction/ValdiFunction class not found, ValdiHooks feature disabled")

View File

@@ -1,5 +1,6 @@
package me.eternal.purrfectsnap.core.features.impl.tweaks
import android.os.Build
import android.view.ViewGroup
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -27,6 +28,7 @@ class VoiceNoteOverride: Feature("Voice Note Override") {
val playbackMap = sortedMapOf<Long, Any>()
val classLoader = context.androidContext.classLoader
var valdiCreateContextWarned = false
fun tryFallbackCreateContext(param: HookAdapter): Any? {
val fallbackClass = runCatching {
@@ -149,6 +151,17 @@ class VoiceNoteOverride: Feature("Voice Note Override") {
PurrfectSnap.classCache.nativeBridge.hook("createContext", HookStage.AFTER) { param ->
val throwable = param.throwable() as? UnsatisfiedLinkError ?: return@hook
val isAndroid9OrBelow = Build.VERSION.SDK_INT < Build.VERSION_CODES.Q
val isValdiBridge = PurrfectSnap.classCache.nativeBridge.name == "com.snapchat.client.valdi.NativeBridge"
if (isAndroid9OrBelow && isValdiBridge) {
if (!valdiCreateContextWarned) {
valdiCreateContextWarned = true
context.log.warn("NativeBridge.createContext missing native impl on Android 9; skipping fallback")
}
param.clearThrowable()
param.setResult(null)
return@hook
}
context.log.error("NativeBridge.createContext missing native impl; attempting fallback", throwable)
val fallback = tryFallbackCreateContext(param)
param.setResult(fallback)