feat(core): auto download voice notes

This commit is contained in:
rhunk
2024-08-13 00:49:05 +02:00
parent e6e33822e7
commit 2f3007964a
11 changed files with 204 additions and 201 deletions

View File

@@ -365,6 +365,10 @@
"name": "Force Voice Note Format",
"description": "Forces Voice Notes to be saved in a specified Format"
},
"auto_download_voice_notes": {
"name": "Auto Download Voice Notes",
"description": "Automatically downloads voice notes when playing them"
},
"download_profile_pictures": {
"name": "Download Profile Pictures",
"description": "Allows you to download Profile Pictures from the profile page"

View File

@@ -41,6 +41,7 @@ class DownloaderConfig : ConfigContainer() {
val forceVoiceNoteFormat = unique("force_voice_note_format", "aac", "mp3", "opus") {
addFlags(ConfigFlag.NO_TRANSLATE)
}
val autoDownloadVoiceNotes = boolean("auto_download_voice_notes") { requireRestart(); addNotices(FeatureNotice.UNSTABLE) }
val downloadProfilePictures = boolean("download_profile_pictures") { requireRestart() }
val operaDownloadButton = boolean("opera_download_button") { requireRestart() }
val downloadContextMenu = boolean("download_context_menu")

View File

@@ -135,7 +135,7 @@ class FeatureManager(
ContextMenuFix(),
DisableTelecomFramework(),
BetterTranscript(),
VoiceNoteAutoPlay(),
VoiceNoteOverride(),
)
features.values.toList().forEach { feature ->

View File

@@ -54,7 +54,6 @@ import me.rhunk.snapenhance.mapper.impl.OperaPageViewControllerMapper
import java.nio.file.Paths
import java.util.UUID
import kotlin.coroutines.suspendCoroutine
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.absoluteValue
class SnapChapterInfo(
@@ -241,6 +240,11 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
)
}
fun canAutoDownloadMessage(databaseMessage: ConversationMessage): Boolean {
if (context.config.downloader.preventSelfAutoDownload.get() && databaseMessage.senderId == context.database.myUserId) return false
return canUseRule(databaseMessage.clientConversationId!!)
}
/**
* Handles the media from the opera viewer
*
@@ -653,7 +657,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
}
@SuppressLint("SetTextI18n")
fun downloadMessageId(messageId: Long, forceAllowDuplicate: Boolean = false, isPreview: Boolean = false) {
fun downloadMessageId(messageId: Long, forceAllowDuplicate: Boolean = false, isPreview: Boolean = false, forceDownloadFirst: Boolean = false) {
val messageLogger = context.feature(MessageLogger::class)
val message = context.database.getConversationMessageFromId(messageId) ?: throw Exception("Message not found in database")
@@ -696,7 +700,8 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
}
if (!isPreview) {
if (decodedAttachments.size == 1 ||
if (forceDownloadFirst ||
decodedAttachments.size == 1 ||
context.isMainActivityPaused // we can't show alert dialogs when it downloads from a notification, so it downloads the first one
) {
downloadMessageAttachments(friendInfo, message, authorName,

View File

@@ -1,156 +0,0 @@
package me.rhunk.snapenhance.core.features.impl.tweaks
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.rhunk.snapenhance.core.event.events.impl.BindViewEvent
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.util.dataBuilder
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hook
import me.rhunk.snapenhance.core.util.ktx.getObjectField
import me.rhunk.snapenhance.mapper.impl.PlaybackViewContextMapper
import java.lang.reflect.Proxy
class VoiceNoteAutoPlay: Feature("Voice Note Auto Play") {
override fun init() {
if (!context.config.experimental.voiceNoteAutoPlay.get()) return
val playbackMap = sortedMapOf<Long, MutableList<Any>>()
fun setPlaybackState(componentContext: Any, state: String): Boolean {
val seek = componentContext.getObjectField("_seek") ?: return false
seek.javaClass.getMethod("invoke", Any::class.java).invoke(seek, 0)
val onPlayButtonTapped = componentContext.getObjectField("_onPlayButtonTapped") ?: return false
onPlayButtonTapped.javaClass.getMethod("invoke", Any::class.java).invoke(
onPlayButtonTapped,
findClass("com.snap.voicenotes.PlaybackState").enumConstants?.first {
it.toString() == state
}
)
return true
}
fun playNextVoiceNote(currentContext: Any) {
val currentContextMessageId = playbackMap.entries.firstOrNull { entry -> entry.value.any { it.hashCode() == currentContext.hashCode() } }?.key ?: return
context.log.verbose("messageId=$currentContextMessageId")
val nextPlayback = playbackMap.entries.firstOrNull { it.key > currentContextMessageId }
if (nextPlayback == null) {
context.log.verbose("No more voice notes to play")
return
}
nextPlayback.value.forEach { setPlaybackState(it, "PLAYING") }
}
context.classCache.conversationManager.apply {
arrayOf("enterConversation", "exitConversation").forEach {
hook(it, HookStage.BEFORE) {
context.coroutineScope.launch(Dispatchers.Main) {
playbackMap.clear()
}
}
}
}
context.mappings.useMapper(PlaybackViewContextMapper::class) {
componentContextClass.getAsClass()?.hook(setOnPlayButtonTapedMethod.get() ?: return@useMapper, HookStage.AFTER) { param ->
val instance = param.thisObject<Any>()
var lastPlayerState: String? = null
instance.dataBuilder {
val onPlayButtonTapped = get("_onPlayButtonTapped") as? Any ?: return@dataBuilder
set("_onPlayButtonTapped", Proxy.newProxyInstance(
context.androidContext.classLoader,
arrayOf(findClass("kotlin.jvm.functions.Function1"))
) { _, _, args ->
lastPlayerState = null
context.log.verbose("onPlayButtonTapped ${args.contentToString()}")
onPlayButtonTapped.javaClass.getMethod("invoke", Any::class.java).invoke(onPlayButtonTapped, args[0])
})
from("_playbackStateObservable") {
val oldSubscribe = get("_subscribe") as? Any ?: return@from
fun subscribe(listener: Any): Any? {
return oldSubscribe.javaClass.getMethod("invoke", Any::class.java).invoke(oldSubscribe, listener)
}
set("_subscribe", Proxy.newProxyInstance(
context.androidContext.classLoader,
arrayOf(findClass("kotlin.jvm.functions.Function1"))
) proxy@{ _, _, args ->
val function4 = args[0]
subscribe(
Proxy.newProxyInstance(
context.androidContext.classLoader,
arrayOf(findClass("kotlin.jvm.functions.Function4"))
) { _, _, listenerArgs ->
val state = listenerArgs[2]?.toString()
if (state == "PAUSED" && lastPlayerState == "PLAYING") {
lastPlayerState = null
context.log.verbose("playback finished. playing next voice note")
runCatching {
context.coroutineScope.launch(Dispatchers.Main) {
playNextVoiceNote(instance)
}
}.onFailure {
context.log.error("Failed to play next voice note", it)
}
}
lastPlayerState = state
function4.javaClass.methods.first { it.parameterCount == 4 }.invoke(function4, *listenerArgs)
}
)
})
}
}
}
}
onNextActivityCreate {
context.event.subscribe(BindViewEvent::class) { event ->
if (!event.prevModel.toString().contains("audio_note")) return@subscribe
event.chatMessage { _, _ ->
// find view model of the audio note
val viewModelField = event.prevModel.javaClass.fields.firstOrNull { field ->
field.type.constructors.firstOrNull()?.parameterTypes?.takeIf { it.size == 3 }?.let { args ->
args[1].interfaces.any { it.name == "com.snap.composer.utils.ComposerMarshallable" }
} == true
} ?: return@subscribe
val viewModel = viewModelField.get(event.prevModel)
var playbackViewComponentContext: Any? = null
for (field in viewModel.javaClass.fields) {
val fieldContent = runCatching { field.get(viewModel) }.getOrNull() ?: continue
if (fieldContent.javaClass.declaredFields.any { it.name == "_onPlayButtonTapped" }) {
playbackViewComponentContext = fieldContent
break;
}
}
if (playbackViewComponentContext == null) {
context.log.warn("Failed to find playback view component context")
return@subscribe
}
context.coroutineScope.launch {
val serverMessageId = event.databaseMessage?.serverMessageId?.toLong() ?: return@launch
withContext(Dispatchers.Main) {
playbackMap.computeIfAbsent(serverMessageId) { mutableListOf() }.add(playbackViewComponentContext)
}
}
}
}
}
}
}

View File

@@ -0,0 +1,152 @@
package me.rhunk.snapenhance.core.features.impl.tweaks
import android.view.ViewGroup
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import me.rhunk.snapenhance.core.SnapEnhance
import me.rhunk.snapenhance.core.event.events.impl.BindViewEvent
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.impl.downloader.MediaDownloader
import me.rhunk.snapenhance.core.features.impl.messaging.Messaging
import me.rhunk.snapenhance.core.ui.getComposerContext
import me.rhunk.snapenhance.core.util.dataBuilder
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hook
import me.rhunk.snapenhance.core.util.ktx.getId
import me.rhunk.snapenhance.core.util.ktx.getObjectField
import me.rhunk.snapenhance.core.util.makeFunctionProxy
class VoiceNoteOverride: Feature("Voice Note Override") {
override fun init() {
val voiceNoteAutoPlay = context.config.experimental.voiceNoteAutoPlay.get()
val autoDownloadVoiceNotes = context.config.downloader.autoDownloadVoiceNotes.get()
if (!autoDownloadVoiceNotes && !voiceNoteAutoPlay) return
val playbackMap = sortedMapOf<Long, MutableList<Any>>()
fun setPlaybackState(componentContext: Any, state: String): Boolean {
val seek = componentContext.getObjectField("_seek") ?: return false
seek.javaClass.getMethod("invoke", Any::class.java).invoke(seek, 0)
val onPlayButtonTapped = componentContext.getObjectField("_onPlayButtonTapped") ?: return false
onPlayButtonTapped.javaClass.getMethod("invoke", Any::class.java).invoke(
onPlayButtonTapped,
findClass("com.snap.voicenotes.PlaybackState").enumConstants?.first {
it.toString() == state
}
)
return true
}
fun getCurrentContextMessageId(currentContext: Any): Long? {
return synchronized(playbackMap) {
playbackMap.entries.firstOrNull { entry -> entry.value.any { it.hashCode() == currentContext.hashCode() } }?.key
}
}
fun playNextVoiceNote(currentContext: Any) {
val currentContextMessageId = getCurrentContextMessageId(currentContext) ?: return
context.log.verbose("messageId=$currentContextMessageId")
val nextPlayback = synchronized(playbackMap) {
playbackMap.entries.firstOrNull { it.key > currentContextMessageId }
}
if (nextPlayback == null) {
context.log.verbose("No more voice notes to play")
return
}
nextPlayback.value.toList().forEach { setPlaybackState(it, "PLAYING") }
}
context.classCache.conversationManager.apply {
arrayOf("enterConversation", "exitConversation").forEach {
hook(it, HookStage.BEFORE) {
synchronized(playbackMap) {
playbackMap.clear()
}
}
}
}
SnapEnhance.classCache.nativeBridge.hook("createContext", HookStage.BEFORE) { param ->
val componentPath = param.arg<String>(1)
val componentContext = param.argNullable<Any>(3)
if (componentPath != "PlaybackView@voice_notes/src/PlaybackView") return@hook
var lastPlayerState: String? = null
componentContext.dataBuilder {
interceptFieldInterface("_onPlayButtonTapped") { args, originalCall ->
lastPlayerState = null
context.log.verbose("onPlayButtonTapped ${args.contentToString()}")
originalCall(args)
}
from("_playbackStateObservable") {
interceptFieldInterface("_subscribe") { subscribeArgs, originalSubscribe ->
originalSubscribe(
arrayOf(
makeFunctionProxy(
subscribeArgs[0]!!
) { args, originalCall ->
val state = args[2]?.toString()
if (autoDownloadVoiceNotes && state != lastPlayerState && state == "PLAYING") {
val currentConversationId = context.feature(Messaging::class).openedConversationUUID.toString()
val currentMessageId = getCurrentContextMessageId(componentContext!!)
val mediaDownloader = context.feature(MediaDownloader::class)
context.coroutineScope.launch {
val databaseMessage = context.database.getConversationServerMessage(currentConversationId, currentMessageId ?: return@launch) ?: throw IllegalStateException("Failed to get database message")
if (mediaDownloader.canAutoDownloadMessage(databaseMessage)) {
mediaDownloader.downloadMessageId(databaseMessage.clientMessageId.toLong(), forceDownloadFirst = true)
}
}
}
if (voiceNoteAutoPlay && state == "PAUSED" && lastPlayerState == "PLAYING") {
lastPlayerState = null
context.log.verbose("playback finished. playing next voice note")
runCatching {
context.coroutineScope.launch(Dispatchers.Main) {
playNextVoiceNote(componentContext!!)
}
}.onFailure {
context.log.error("Failed to play next voice note", it)
}
}
lastPlayerState = state
originalCall(args)
}
)
)
}
}
}
}
onNextActivityCreate {
context.event.subscribe(BindViewEvent::class) { event ->
event.chatMessage { _, _ ->
val messagePluginContentHolder = event.view.findViewById<ViewGroup>(context.resources.getId("plugin_content_holder")) ?: return@subscribe
val composerRootView = messagePluginContentHolder.getChildAt(0) ?: return@subscribe
val composerContext = composerRootView.getComposerContext() ?: return@subscribe
val playbackViewComponentContext = composerContext.componentContext?.get() ?: return@subscribe
val serverMessageId = event.databaseMessage?.serverMessageId?.toLong() ?: return@subscribe
synchronized(playbackMap) {
playbackMap.computeIfAbsent(serverMessageId) { mutableListOf() }.add(playbackViewComponentContext)
}
}
}
}
}
}

View File

@@ -108,18 +108,17 @@ fun View.findParent(maxIteration: Int = Int.MAX_VALUE, predicate: (View) -> Bool
fun View.getComposerViewNode(): ComposerViewNode? {
if (!this::class.java.isAssignableFrom(SnapEnhance.classCache.composerView)) return null
if (!SnapEnhance.classCache.composerView.isInstance(this)) return null
val composerViewNode = this::class.java.methods.firstOrNull {
it.name == "getComposerViewNode"
}?.invoke(this) ?: return null
return ComposerViewNode(composerViewNode::class.java.methods.firstOrNull {
it.name == "getNativeHandle"
}?.invoke(composerViewNode) as? Long ?: return null)
return ComposerViewNode.fromNode(composerViewNode)
}
fun View.getComposerContext(): ComposerContext? {
if (!this::class.java.isAssignableFrom(SnapEnhance.classCache.composerView)) return null
if (!SnapEnhance.classCache.composerView.isInstance(this)) return null
return ComposerContext(this::class.java.methods.firstOrNull {
it.name == "getComposerContext"

View File

@@ -1,5 +1,7 @@
package me.rhunk.snapenhance.core.util
import java.lang.reflect.Proxy
inline fun Any?.dataBuilder(dataClassBuilder: DataClassBuilder.() -> Unit): Any? {
return DataClassBuilder(
@@ -12,18 +14,34 @@ inline fun Any?.dataBuilder(dataClassBuilder: DataClassBuilder.() -> Unit): Any?
).apply(dataClassBuilder).build()
}
fun makeFunctionProxy(function: Any, handler: (args: Array<Any?>, originalCall: (Array<Any?>) -> Any?) -> Any?): Any {
val type = function.javaClass.interfaces.firstOrNull() ?: function.javaClass
val method = type.methods.firstOrNull {
it.declaringClass == type
}
return Proxy.newProxyInstance(
function::class.java.classLoader,
arrayOf(type)
) { _, _, args ->
handler(args) { newArgs ->
method?.invoke(function, *newArgs)
}
}
}
// Util for building/editing data classes
class DataClassBuilder(
private val instance: Any,
) {
fun set(fieldName: String, value: Any?) {
val field = instance::class.java.declaredFields.firstOrNull { it.name == fieldName } ?: return
val fieldType = field.type
val fieldType = field.type ?: return
field.isAccessible = true
when {
fieldType.isEnum -> {
val enumValue = fieldType.enumConstants.firstOrNull { it.toString() == value } ?: return
val enumValue = fieldType.enumConstants?.firstOrNull { it.toString() == value } ?: return
field.set(instance, enumValue)
}
fieldType.isPrimitive -> {
@@ -69,5 +87,11 @@ class DataClassBuilder(
type.cast(instance)?.let { callback(it) }
}
fun interceptFieldInterface(fieldName: String, callback: (args: Array<Any?>, originalCall: (Array<Any?>) -> Any?) -> Any?) {
val field = instance.javaClass.declaredFields.firstOrNull { it.name == fieldName } ?: return
field.isAccessible = true
set(fieldName, field.get(instance)?.let { makeFunctionProxy(it, callback) } ?: return)
}
fun build() = instance
}

View File

@@ -16,6 +16,14 @@ fun createComposerFunction(block: (args: Array<*>) -> Any?): Any {
}
class ComposerViewNode(obj: Long) : AbstractWrapper(obj) {
companion object {
fun fromNode(composerViewNode: Any?): ComposerViewNode? {
return (composerViewNode?.javaClass?.methods?.firstOrNull {
it.name == "getNativeHandle"
}?.invoke(composerViewNode) as? Long)?.let { ComposerViewNode(it) } ?: return null
}
}
fun getAttribute(name: String): Any? {
return SnapEnhance.classCache.nativeBridge.methods.firstOrNull {
it.name == "getValueForAttribute"

View File

@@ -37,7 +37,6 @@ class ClassMapper(
MemoriesPresenterMapper(),
StreaksExpirationMapper(),
COFObservableMapper(),
PlaybackViewContextMapper(),
)
}

View File

@@ -1,33 +0,0 @@
package me.rhunk.snapenhance.mapper.impl
import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction22c
import com.android.tools.smali.dexlib2.iface.reference.FieldReference
import me.rhunk.snapenhance.mapper.AbstractClassMapper
import me.rhunk.snapenhance.mapper.ext.getClassName
class PlaybackViewContextMapper: AbstractClassMapper("Playback View Context Mapper") {
val componentContextClass = classReference("componentContextClass")
val setOnPlayButtonTapedMethod = string("setOnPlayButtonTapedMethod")
init {
mapper {
val playbackViewClass = getClass("Lcom/snap/voicenotes/PlaybackView;") ?: return@mapper
val componentContextDexClass = getClass(playbackViewClass.methods.firstOrNull {
it.name == "create" && it.parameters.size > 3
}?.parameterTypes?.get(2)) ?: return@mapper
componentContextClass.set(componentContextDexClass.getClassName())
val setOnPlayButtonTapedDexMethod = componentContextDexClass.methods.firstOrNull { method ->
method.name != "<init>" && method.implementation?.instructions?.any {
if (it is Instruction22c && it.reference is FieldReference) {
(it.reference as FieldReference).name == "_onPlayButtonTapped"
} else false
} == true
} ?: return@mapper
setOnPlayButtonTapedMethod.set(setOnPlayButtonTapedDexMethod.name)
}
}
}