commit 115b5ce0d8b465651553daa64f10306bdf75d8fc
parent ea284bc9aac2925b442ee4d498ab62f664296012
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date:   Wed, 24 Jul 2024 05:12:41 +0200

feat(core): voice note auto play

Signed-off-by: rhunk <101876869+rhunk@users.noreply.github.com>

Diffstat:
Mcommon/src/main/assets/lang/en_US.json | 4++++
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Experimental.kt | 1+
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/features/FeatureManager.kt | 1+
Acore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/VoiceNoteAutoPlay.kt | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmapper/src/main/kotlin/me/rhunk/snapenhance/mapper/ClassMapper.kt | 1+
Amapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/PlaybackViewContextMapper.kt | 34++++++++++++++++++++++++++++++++++
6 files changed, 184 insertions(+), 0 deletions(-)

diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json @@ -1039,6 +1039,10 @@ } } }, + "voice_note_auto_play": { + "name": "Voice Note Auto Play", + "description": "Automatically plays the next voice note after the current one finishes" + }, "cof_experiments": { "name": "COF Experiments", "description": "Enables unreleased/beta Snapchat features" diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Experimental.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Experimental.kt @@ -63,6 +63,7 @@ class Experimental : ConfigContainer() { val callRecorder = boolean("call_recorder") { requireRestart(); addNotices(FeatureNotice.UNSTABLE); } val accountSwitcher = container("account_switcher", AccountSwitcherConfig()) { requireRestart(); addNotices(FeatureNotice.UNSTABLE) } val betterTranscript = container("better_transcript", BetterTranscriptConfig()) { requireRestart() } + val voiceNoteAutoPlay = boolean("voice_note_auto_play") { requireRestart() } val editMessage = boolean("edit_message") { requireRestart() } val contextMenuFix = boolean("context_menu_fix") { requireRestart() } val cofExperiments = multiple("cof_experiments", *cofExperimentList.toTypedArray()) { requireRestart(); addFlags(ConfigFlag.NO_TRANSLATE); addNotices(FeatureNotice.UNSTABLE) } 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 @@ -134,6 +134,7 @@ class FeatureManager( ContextMenuFix(), DisableTelecomFramework(), BetterTranscript(), + VoiceNoteAutoPlay(), ) features.values.toList().forEach { feature -> 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 @@ -0,0 +1,142 @@ +package me.rhunk.snapenhance.core.features.impl.tweaks + +import android.view.View +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 +import java.util.WeakHashMap + +class VoiceNoteAutoPlay: Feature("Voice Note Auto Play") { + override fun init() { + if (!context.config.experimental.voiceNoteAutoPlay.get()) return + + val views = WeakHashMap<View, Any>() // component context -> view + + fun setPlaybackState(componentContext: Any, state: String): Boolean { + 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): Boolean { + val currentContextView = views.entries.filter { it.key.isAttachedToWindow }.firstOrNull { it.value.hashCode() == currentContext.hashCode() }?.key ?: return false + + val currentViewLocation = IntArray(2) + currentContextView.getLocationOnScreen(currentViewLocation) + + context.log.verbose("currentView -> $currentContextView") + + // find a view under the current view + val nextView = views.entries.filter { it.key.isAttachedToWindow }.map { entry -> + entry to IntArray(2).let { + entry.key.getLocationOnScreen(it) + it[1] - currentViewLocation[1] + } + }.filter { it.second > 1 }.minByOrNull { it.second }?.first + + if (nextView == null) { + context.log.verbose("No more voice notes to play") + return false + } + + context.log.verbose("nextView -> ${nextView.key}") + + return setPlaybackState(nextView.value, "PLAYING") + } + + 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") { + context.log.verbose("playback finished. playing next voice note") + runCatching { + if (!playNextVoiceNote(instance)) { + lastPlayerState = null + } + }.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 + + // 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; + } + } + + views[event.view] = playbackViewComponentContext ?: return@subscribe + } + } + } +}+ \ No newline at end of file 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,6 +37,7 @@ 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 @@ -0,0 +1,33 @@ +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