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:
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