commit 2f3007964ae812f7afdc34f71061852d85a62671
parent e6e33822e70e361d4131db9513dca9b558890dec
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date:   Tue, 13 Aug 2024 00:49:05 +0200

feat(core): auto download voice notes

Diffstat:
Mcommon/src/main/assets/lang/en_US.json | 4++++
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/DownloaderConfig.kt | 1+
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/features/FeatureManager.kt | 2+-
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt | 11++++++++---
Dcore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/VoiceNoteAutoPlay.kt | 157-------------------------------------------------------------------------------
Acore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/VoiceNoteOverride.kt | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/ui/ViewAppearanceHelper.kt | 9++++-----
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/util/DataClassBuilder.kt | 28++++++++++++++++++++++++++--
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/composer/ComposerViewNode.kt | 8++++++++
Mmapper/src/main/kotlin/me/rhunk/snapenhance/mapper/ClassMapper.kt | 1-
Dmapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/PlaybackViewContextMapper.kt | 34----------------------------------
11 files changed, 205 insertions(+), 203 deletions(-)

diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json @@ -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" diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/DownloaderConfig.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/DownloaderConfig.kt @@ -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") diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/FeatureManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/FeatureManager.kt @@ -135,7 +135,7 @@ class FeatureManager( ContextMenuFix(), DisableTelecomFramework(), BetterTranscript(), - VoiceNoteAutoPlay(), + VoiceNoteOverride(), ) features.values.toList().forEach { feature -> diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt @@ -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, diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/VoiceNoteAutoPlay.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/VoiceNoteAutoPlay.kt @@ -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) - } - } - } - } - } - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/VoiceNoteOverride.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/VoiceNoteOverride.kt @@ -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) + } + } + } + } + } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/ViewAppearanceHelper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/ViewAppearanceHelper.kt @@ -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" diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/DataClassBuilder.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/DataClassBuilder.kt @@ -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 } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/composer/ComposerViewNode.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/composer/ComposerViewNode.kt @@ -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" diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/ClassMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/ClassMapper.kt @@ -37,7 +37,6 @@ class ClassMapper( MemoriesPresenterMapper(), StreaksExpirationMapper(), COFObservableMapper(), - PlaybackViewContextMapper(), ) } diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/PlaybackViewContextMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/PlaybackViewContextMapper.kt @@ -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) - } - } -}- \ No newline at end of file