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:
@@ -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)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.*
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user