refactor: feature load system

Signed-off-by: rhunk <101876869+rhunk@users.noreply.github.com>
This commit is contained in:
rhunk
2024-06-30 23:58:28 +02:00
parent bf49bcbe11
commit d31591fd47
84 changed files with 1702 additions and 1733 deletions

View File

@@ -79,7 +79,7 @@ class ModContext(
}
fun runOnUiThread(runnable: () -> Unit) {
if (Looper.myLooper() == Looper.getMainLooper()) {
if (Looper.getMainLooper().isCurrentThread) {
runnable()
return
}
@@ -95,7 +95,7 @@ class ModContext(
runCatching {
runnable()
}.onFailure {
longToast("Async task failed " + it.message)
longToast("Async task failed: " + it.message)
log.error("Async task failed", it)
}
}

View File

@@ -106,7 +106,7 @@ class SnapEnhance {
appContext.mainActivity = this
if (!appContext.mappings.isMappingsLoaded) return@hookMainActivity
appContext.isMainActivityPaused = false
onActivityCreate()
onActivityCreate(this)
appContext.actionManager.onNewIntent(intent)
}
@@ -166,12 +166,12 @@ class SnapEnhance {
}
}
private fun onActivityCreate() {
private fun onActivityCreate(activity: Activity) {
measureTimeMillis {
with(appContext) {
features.onActivityCreate()
inAppOverlay.onActivityCreate(mainActivity!!)
scriptRuntime.eachModule { callFunction("module.onSnapMainActivityCreate", mainActivity!!) }
features.onActivityCreate(activity)
inAppOverlay.onActivityCreate(activity)
scriptRuntime.eachModule { callFunction("module.onSnapMainActivityCreate", activity) }
actionManager.onActivityCreate()
}
}.also { time ->

View File

@@ -9,7 +9,7 @@ import java.io.BufferedReader
import java.io.InputStreamReader
import java.nio.charset.StandardCharsets
abstract class BridgeFileFeature(name: String, private val bridgeFileType: InternalFileHandleType, loadParams: Int) : Feature(name, loadParams) {
abstract class BridgeFileFeature(name: String, private val bridgeFileType: InternalFileHandleType) : Feature(name) {
private val fileLines = mutableListOf<String>()
private val fileWrapper by mappedLazyBridge(LazyBridgeValue({ context.fileHandlerManager.getFileHandle(FileHandleScope.INTERNAL.key, bridgeFileType.key)!! }), map = { it.toWrapper() })

View File

@@ -1,33 +1,39 @@
package me.rhunk.snapenhance.core.features
import android.app.Activity
import kotlinx.coroutines.launch
import me.rhunk.snapenhance.core.ModContext
abstract class Feature(
val featureKey: String,
val loadParams: Int = FeatureLoadParams.INIT_SYNC
val key: String
) {
lateinit var context: ModContext
lateinit var registerNextActivityCallback: ((Activity) -> Unit) -> Unit
protected fun defer(block: () -> Unit) {
context.coroutineScope.launch {
runCatching {
block()
}.onFailure {
context.log.error("Failed to run onNextActivityCreate callback", it)
}
}
}
protected fun onNextActivityCreate(defer: Boolean = false, block: (Activity) -> Unit) {
if (defer) {
registerNextActivityCallback {
defer {
block(it)
}
}
return
}
registerNextActivityCallback(block)
}
/**
* called on the main thread when the mod initialize
*/
open fun init() {}
/**
* called on a dedicated thread when the mod initialize
*/
open fun asyncInit() {}
/**
* called when the Snapchat Activity is created
*/
open fun onActivityCreate() {}
/**
* called on a dedicated thread when the Snapchat Activity is created
*/
open fun asyncOnActivityCreate() {}
protected fun findClass(name: String): Class<*> {
return context.androidContext.classLoader.loadClass(name)

View File

@@ -1,5 +1,6 @@
package me.rhunk.snapenhance.core.features
import android.app.Activity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
@@ -25,6 +26,7 @@ class FeatureManager(
private val context: ModContext
) {
private val features = mutableMapOf<KClass<out Feature>, Feature>()
private val onActivityCreateListeners = mutableListOf<(Activity) -> Unit>()
private fun register(vararg featureList: Feature) {
if (context.bridgeClient.getDebugProp("disable_feature_loading") == "true") {
@@ -37,11 +39,12 @@ class FeatureManager(
launch(Dispatchers.IO) {
runCatching {
feature.context = context
feature.registerNextActivityCallback = { block -> onActivityCreateListeners.add(block) }
synchronized(features) {
features[feature::class] = feature
}
}.onFailure {
CoreLogger.xposedLog("Failed to register feature ${feature.featureKey}", it)
CoreLogger.xposedLog("Failed to register feature ${feature.key}", it)
}
}
}
@@ -132,65 +135,35 @@ class FeatureManager(
DisableTelecomFramework(),
BetterTranscript(),
)
initializeFeatures()
}
private inline fun tryInit(feature: Feature, crossinline block: () -> Unit) {
runCatching {
block()
}.onFailure {
context.log.error("Failed to init feature ${feature.featureKey}", it)
context.longToast("Failed to init feature ${feature.featureKey}! Check logcat for more details.")
}
}
private fun initFeatures(
syncParam: Int,
asyncParam: Int,
syncAction: (Feature) -> Unit,
asyncAction: (Feature) -> Unit
) {
features.values.toList().forEach { feature ->
if (feature.loadParams and syncParam != 0) {
tryInit(feature) {
syncAction(feature)
}
}
if (feature.loadParams and asyncParam != 0) {
context.coroutineScope.launch {
tryInit(feature) {
asyncAction(feature)
}
runCatching {
measureTimeMillis {
feature.init()
}.also {
context.log.verbose("Feature ${feature.key} initialized in $it ms")
}
}.onFailure {
context.log.error("Failed to init feature ${feature.key}", it)
context.longToast("Failed to init feature ${feature.key}! Check logcat for more details.")
}
}
}
private fun initializeFeatures() {
//TODO: async called when all features are initiated ?
measureTimeMillis {
initFeatures(
FeatureLoadParams.INIT_SYNC,
FeatureLoadParams.INIT_ASYNC,
Feature::init,
Feature::asyncInit
)
}.also {
context.log.verbose("feature manager init took $it ms")
}
}
fun onActivityCreate() {
measureTimeMillis {
initFeatures(
FeatureLoadParams.ACTIVITY_CREATE_SYNC,
FeatureLoadParams.ACTIVITY_CREATE_ASYNC,
Feature::onActivityCreate,
Feature::asyncOnActivityCreate
)
}.also {
context.log.verbose("feature manager onActivityCreate took $it ms")
fun onActivityCreate(activity: Activity) {
context.log.verbose("Activity created: ${activity.javaClass.simpleName}")
onActivityCreateListeners.toList().also {
onActivityCreateListeners.clear()
}.forEach { activityListener ->
measureTimeMillis {
runCatching {
activityListener(activity)
}.onFailure {
context.log.error("Failed to run activity listener ${activityListener::class.simpleName}", it)
}
}.also {
context.log.verbose("Activity listener ${activityListener::class.simpleName} executed in $it ms")
}
}
}
}

View File

@@ -3,7 +3,7 @@ package me.rhunk.snapenhance.core.features
import me.rhunk.snapenhance.common.data.MessagingRuleType
import me.rhunk.snapenhance.common.data.RuleState
abstract class MessagingRuleFeature(name: String, val ruleType: MessagingRuleType, loadParams: Int = 0) : Feature(name, loadParams) {
abstract class MessagingRuleFeature(name: String, val ruleType: MessagingRuleType) : Feature(name) {
private val listeners = mutableListOf<(String, Boolean) -> Unit>()
fun addStateListener(listener: (conversationId: String, newState: Boolean) -> Unit) {

View File

@@ -1,7 +1,7 @@
package me.rhunk.snapenhance.core.features.impl
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.util.dataBuilder
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hook
@@ -9,7 +9,7 @@ import me.rhunk.snapenhance.core.util.ktx.getObjectField
import me.rhunk.snapenhance.mapper.impl.COFObservableMapper
import java.lang.reflect.Method
class COFOverride : Feature("COF Override", loadParams = FeatureLoadParams.INIT_SYNC) {
class COFOverride : Feature("COF Override") {
var hasActionMenuV2 = false
override fun init() {

View File

@@ -2,7 +2,7 @@ package me.rhunk.snapenhance.core.features.impl
import de.robv.android.xposed.XposedHelpers
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.Hooker
import me.rhunk.snapenhance.core.util.hook.hook
@@ -22,7 +22,7 @@ data class ConfigFilter(
val isAppExperiment: Boolean?
)
class ConfigurationOverride : Feature("Configuration Override", loadParams = FeatureLoadParams.INIT_SYNC) {
class ConfigurationOverride : Feature("Configuration Override") {
override fun init() {
context.mappings.useMapper(CompositeConfigurationProviderMapper::class) {
fun getConfigKeyInfo(key: Any?) = runCatching {

View File

@@ -10,12 +10,12 @@ import me.rhunk.snapenhance.common.data.FriendLinkType
import me.rhunk.snapenhance.common.database.impl.FriendInfo
import me.rhunk.snapenhance.core.event.events.impl.NetworkApiRequestEvent
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.util.EvictingMap
import java.io.InputStreamReader
import java.util.Calendar
class FriendMutationObserver: Feature("FriendMutationObserver", loadParams = FeatureLoadParams.INIT_SYNC) {
class FriendMutationObserver: Feature("FriendMutationObserver") {
private val translation by lazy { context.translation.getCategory("friend_mutation_observer") }
private val addSourceCache = EvictingMap<String, String>(500)

View File

@@ -7,13 +7,13 @@ import me.rhunk.snapenhance.common.data.StoryData
import me.rhunk.snapenhance.common.util.protobuf.ProtoEditor
import me.rhunk.snapenhance.core.event.events.impl.NetworkApiRequestEvent
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import java.nio.ByteBuffer
import kotlin.coroutines.suspendCoroutine
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
class MixerStories : Feature("MixerStories", loadParams = FeatureLoadParams.INIT_SYNC) {
class MixerStories : Feature("MixerStories") {
@OptIn(ExperimentalEncodingApi::class)
override fun init() {
val disableDiscoverSections by context.config.global.disableStorySections

View File

@@ -1,12 +1,11 @@
package me.rhunk.snapenhance.core.features.impl
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hook
import me.rhunk.snapenhance.mapper.impl.OperaViewerParamsMapper
class OperaViewerParamsOverride : Feature("OperaViewerParamsOverride", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
class OperaViewerParamsOverride : Feature("OperaViewerParamsOverride") {
var currentPlaybackRate = 1.0F
data class OverrideKey(
@@ -19,7 +18,7 @@ class OperaViewerParamsOverride : Feature("OperaViewerParamsOverride", loadParam
val value: (key: OverrideKey, value: Any?) -> Any?
)
override fun onActivityCreate() {
override fun init() {
val overrideMap = mutableMapOf<String, Override>()
fun overrideParam(key: String, filter: (value: Any?) -> Boolean, value: (overrideKey: OverrideKey, value: Any?) -> Any?) {
@@ -44,37 +43,39 @@ class OperaViewerParamsOverride : Feature("OperaViewerParamsOverride", loadParam
})
}
context.mappings.useMapper(OperaViewerParamsMapper::class) {
fun overrideParamResult(paramKey: Any, value: Any?): Any? {
val fields = paramKey::class.java.fields
val key = OverrideKey(
name = fields.firstOrNull {
it.type == String::class.java
}?.get(paramKey)?.toString() ?: return value,
defaultValue = fields.firstOrNull {
it.type == Object::class.java
}?.get(paramKey)
)
onNextActivityCreate {
context.mappings.useMapper(OperaViewerParamsMapper::class) {
fun overrideParamResult(paramKey: Any, value: Any?): Any? {
val fields = paramKey::class.java.fields
val key = OverrideKey(
name = fields.firstOrNull {
it.type == String::class.java
}?.get(paramKey)?.toString() ?: return value,
defaultValue = fields.firstOrNull {
it.type == Object::class.java
}?.get(paramKey)
)
overrideMap[key.name]?.let { override ->
if (override.filter(value)) {
runCatching {
return override.value(key, value)
}.onFailure {
context.log.error("Failed to override param $key", it)
overrideMap[key.name]?.let { override ->
if (override.filter(value)) {
runCatching {
return override.value(key, value)
}.onFailure {
context.log.error("Failed to override param $key", it)
}
}
}
return value
}
return value
}
classReference.get()?.hook(getMethod.get()!!, HookStage.AFTER) { param ->
param.setResult(overrideParamResult(param.arg(0), param.getResult()))
}
classReference.get()?.hook(getMethod.get()!!, HookStage.AFTER) { param ->
param.setResult(overrideParamResult(param.arg(0), param.getResult()))
}
classReference.get()?.hook(getOrDefaultMethod.get()!!, HookStage.AFTER) { param ->
param.setResult(overrideParamResult(param.arg(0), param.getResult()))
classReference.get()?.hook(getOrDefaultMethod.get()!!, HookStage.AFTER) { param ->
param.setResult(overrideParamResult(param.arg(0), param.getResult()))
}
}
}
}

View File

@@ -7,9 +7,8 @@ import me.rhunk.snapenhance.common.data.ContentType
import me.rhunk.snapenhance.common.data.SocialScope
import me.rhunk.snapenhance.core.event.events.impl.SendMessageWithContentEvent
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
class ScopeSync : Feature("Scope Sync", loadParams = FeatureLoadParams.INIT_SYNC) {
class ScopeSync : Feature("Scope Sync") {
companion object {
private const val DELAY_BEFORE_SYNC = 2000L
}

View File

@@ -31,7 +31,6 @@ import me.rhunk.snapenhance.common.util.snap.MediaDownloaderHelper
import me.rhunk.snapenhance.common.util.snap.RemoteMediaResolver
import me.rhunk.snapenhance.core.DownloadManagerClient
import me.rhunk.snapenhance.core.SnapEnhance
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.features.MessagingRuleFeature
import me.rhunk.snapenhance.core.features.impl.downloader.decoder.DecodedAttachment
import me.rhunk.snapenhance.core.features.impl.downloader.decoder.MessageDecoder
@@ -64,8 +63,7 @@ class SnapChapterInfo(
)
@OptIn(ExperimentalEncodingApi::class)
class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleType.AUTO_DOWNLOAD, loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) {
class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleType.AUTO_DOWNLOAD) {
private var lastSeenMediaInfoMap: MutableMap<SplitMediaAssetType, MediaInfo>? = null
var lastSeenMapParams: ParamMap? = null
private set
@@ -487,50 +485,52 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
return options.any { keyFilter == null || it.contains(keyFilter, true) }
}
override fun asyncOnActivityCreate() {
context.mappings.useMapper(OperaPageViewControllerMapper::class) {
val onOperaViewStateCallback: (HookAdapter) -> Unit = onOperaViewStateCallback@{ param ->
val viewState = (param.thisObject() as Any).getObjectField(viewStateField.get()!!).toString()
if (viewState != "FULLY_DISPLAYED") {
return@onOperaViewStateCallback
}
val operaLayerList = (param.thisObject() as Any).getObjectField(layerListField.get()!!) as ArrayList<*>
val mediaParamMap: ParamMap = operaLayerList.map { Layer(it) }.first().paramMap
override fun init() {
onNextActivityCreate {
context.mappings.useMapper(OperaPageViewControllerMapper::class) {
val onOperaViewStateCallback: (HookAdapter) -> Unit = onOperaViewStateCallback@{ param ->
val viewState = (param.thisObject() as Any).getObjectField(viewStateField.get()!!).toString()
if (viewState != "FULLY_DISPLAYED") {
return@onOperaViewStateCallback
}
val operaLayerList = (param.thisObject() as Any).getObjectField(layerListField.get()!!) as ArrayList<*>
val mediaParamMap: ParamMap = operaLayerList.map { Layer(it) }.first().paramMap
if (!mediaParamMap.containsKey("image_media_info") && !mediaParamMap.containsKey("video_media_info_list"))
return@onOperaViewStateCallback
if (!mediaParamMap.containsKey("image_media_info") && !mediaParamMap.containsKey("video_media_info_list"))
return@onOperaViewStateCallback
val mediaInfoMap = mutableMapOf<SplitMediaAssetType, MediaInfo>()
val isVideo = mediaParamMap.containsKey("video_media_info_list")
val mediaInfoMap = mutableMapOf<SplitMediaAssetType, MediaInfo>()
val isVideo = mediaParamMap.containsKey("video_media_info_list")
mediaInfoMap[SplitMediaAssetType.ORIGINAL] = MediaInfo(
(if (isVideo) mediaParamMap["video_media_info_list"] else mediaParamMap["image_media_info"])!!
)
mediaInfoMap[SplitMediaAssetType.ORIGINAL] = MediaInfo(
(if (isVideo) mediaParamMap["video_media_info_list"] else mediaParamMap["image_media_info"])!!
)
if (context.config.downloader.mergeOverlays.get() && mediaParamMap.containsKey("overlay_image_media_info")) {
mediaInfoMap[SplitMediaAssetType.OVERLAY] =
MediaInfo(mediaParamMap["overlay_image_media_info"]!!)
}
lastSeenMapParams = mediaParamMap
lastSeenMediaInfoMap = mediaInfoMap
if (context.config.downloader.mergeOverlays.get() && mediaParamMap.containsKey("overlay_image_media_info")) {
mediaInfoMap[SplitMediaAssetType.OVERLAY] =
MediaInfo(mediaParamMap["overlay_image_media_info"]!!)
}
lastSeenMapParams = mediaParamMap
lastSeenMediaInfoMap = mediaInfoMap
if (!canAutoDownload()) return@onOperaViewStateCallback
if (!canAutoDownload()) return@onOperaViewStateCallback
context.executeAsync {
runCatching {
handleOperaMedia(mediaParamMap, mediaInfoMap, false)
}.onFailure {
context.log.error("Failed to handle opera media", it)
context.longToast(it.message)
context.executeAsync {
runCatching {
handleOperaMedia(mediaParamMap, mediaInfoMap, false)
}.onFailure {
context.log.error("Failed to handle opera media", it)
context.longToast(it.message)
}
}
}
}
arrayOf(onDisplayStateChange, onDisplayStateChangeGesture).forEach { methodName ->
classReference.get()?.hook(
methodName.get() ?: return@forEach,
HookStage.AFTER, onOperaViewStateCallback
)
arrayOf(onDisplayStateChange, onDisplayStateChangeGesture).forEach { methodName ->
classReference.get()?.hook(
methodName.get() ?: return@forEach,
HookStage.AFTER, onOperaViewStateCallback
)
}
}
}
}

View File

@@ -7,61 +7,62 @@ import me.rhunk.snapenhance.common.util.protobuf.ProtoReader
import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent
import me.rhunk.snapenhance.core.event.events.impl.NetworkApiRequestEvent
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper
class ProfilePictureDownloader : Feature("ProfilePictureDownloader", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) {
class ProfilePictureDownloader : Feature("ProfilePictureDownloader") {
@SuppressLint("SetTextI18n")
override fun asyncOnActivityCreate() {
override fun init() {
if (!context.config.downloader.downloadProfilePictures.get()) return
var friendUsername: String? = null
var backgroundUrl: String? = null
var avatarUrl: String? = null
context.event.subscribe(AddViewEvent::class) { event ->
if (event.view::class.java.name != "com.snap.unifiedpublicprofile.UnifiedPublicProfileView") return@subscribe
onNextActivityCreate(defer = true) {
context.event.subscribe(AddViewEvent::class) { event ->
if (event.view::class.java.name != "com.snap.unifiedpublicprofile.UnifiedPublicProfileView") return@subscribe
event.parent.addView(Button(event.parent.context).apply {
text = this@ProfilePictureDownloader.context.translation["profile_picture_downloader.button"]
layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT).apply {
setMargins(0, 200, 0, 0)
}
setOnClickListener {
ViewAppearanceHelper.newAlertDialogBuilder(
this@ProfilePictureDownloader.context.mainActivity!!
).apply {
setTitle(this@ProfilePictureDownloader.context.translation["profile_picture_downloader.title"])
val choices = mutableMapOf<String, String>()
backgroundUrl?.let { choices["background_option"] = it }
avatarUrl?.let { choices["avatar_option"] = it }
event.parent.addView(Button(event.parent.context).apply {
text = this@ProfilePictureDownloader.context.translation["profile_picture_downloader.button"]
layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT).apply {
setMargins(0, 200, 0, 0)
}
setOnClickListener {
ViewAppearanceHelper.newAlertDialogBuilder(
this@ProfilePictureDownloader.context.mainActivity!!
).apply {
setTitle(this@ProfilePictureDownloader.context.translation["profile_picture_downloader.title"])
val choices = mutableMapOf<String, String>()
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)
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)
}
}
}.show()
}
})
}
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) {
friendUsername = getString(2) ?: return@followPath
followPath(4) {
backgroundUrl = getString(2)
avatarUrl = getString(100)
}
}.show()
}
})
}
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) {
friendUsername = getString(2) ?: return@followPath
followPath(4) {
backgroundUrl = getString(2)
avatarUrl = getString(100)
}
}
}

View File

@@ -36,7 +36,6 @@ import me.rhunk.snapenhance.common.util.snap.MediaDownloaderHelper
import me.rhunk.snapenhance.core.event.events.impl.ActivityResultEvent
import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hook
import me.rhunk.snapenhance.core.util.ktx.getId
@@ -47,7 +46,7 @@ import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
import kotlin.random.Random
class AccountSwitcher: Feature("Account Switcher", loadParams = FeatureLoadParams.INIT_SYNC or FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
class AccountSwitcher: Feature("Account Switcher") {
private var exportCallback: Pair<Int, String>? = null // requestCode -> userId
private var importRequestCode: Int? = null
@@ -425,26 +424,24 @@ class AccountSwitcher: Feature("Account Switcher", loadParams = FeatureLoadParam
mainDbShmFile?.delete()
}
override fun onActivityCreate() {
if (context.config.experimental.accountSwitcher.globalState != true) return
val hovaHeaderSearchIcon = context.mainActivity!!.resources.getId("hova_header_search_icon")
context.event.subscribe(AddViewEvent::class) { event ->
if (event.view.id != hovaHeaderSearchIcon) return@subscribe
event.view.setOnLongClickListener {
context.mainActivity!!.vibrateLongPress()
showManagementPopup()
false
}
}
}
@SuppressLint("SetTextI18n")
override fun init() {
if (context.config.experimental.accountSwitcher.globalState != true) return
onNextActivityCreate {
val hovaHeaderSearchIcon = context.resources.getId("hova_header_search_icon")
context.event.subscribe(AddViewEvent::class) { event ->
if (event.view.id != hovaHeaderSearchIcon) return@subscribe
event.view.setOnLongClickListener {
context.mainActivity!!.vibrateLongPress()
showManagementPopup()
false
}
}
}
context.event.subscribe(ActivityResultEvent::class) { event ->
if (importRequestCode == event.requestCode) {
importRequestCode = null
@@ -501,7 +498,7 @@ class AccountSwitcher: Feature("Account Switcher", loadParams = FeatureLoadParam
}
findClass("com.snap.identity.loginsignup.ui.LoginSignupActivity").apply {
hook("onPostCreate", HookStage.AFTER) { param ->
hook("onPostCreate", HookStage.AFTER) {
context.mainActivity!!.findViewById<FrameLayout>(android.R.id.content).addView(createComposeView(context.mainActivity!!) {
Row(
modifier = Modifier.fillMaxWidth(),

View File

@@ -4,68 +4,69 @@ import me.rhunk.snapenhance.common.data.FriendAddSource
import me.rhunk.snapenhance.common.util.protobuf.ProtoEditor
import me.rhunk.snapenhance.core.event.events.impl.UnaryCallEvent
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hookConstructor
import me.rhunk.snapenhance.mapper.impl.FriendRelationshipChangerMapper
class AddFriendSourceSpoof : Feature("AddFriendSourceSpoof", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
class AddFriendSourceSpoof : Feature("AddFriendSourceSpoof") {
var friendRelationshipChangerInstance: Any? = null
private set
override fun onActivityCreate() {
context.mappings.useMapper(FriendRelationshipChangerMapper::class) {
classReference.get()?.hookConstructor(HookStage.AFTER) { param ->
friendRelationshipChangerInstance = param.thisObject()
}
}
context.event.subscribe(UnaryCallEvent::class) { event ->
if (event.uri != "/snapchat.friending.server.FriendAction/AddFriends") return@subscribe
val spoofedSource = context.config.experimental.addFriendSourceSpoof.getNullable() ?: return@subscribe
event.buffer = ProtoEditor(event.buffer).apply {
edit {
fun setPage(value: String) {
remove(1)
addString(1, value)
}
editEach(2) {
remove(3) // remove suggestion token
fun setSource(source: FriendAddSource) {
remove(2)
addVarInt(2, source.id)
}
when (spoofedSource) {
"added_by_group_chat" -> {
setPage("group_profile")
setSource(FriendAddSource.GROUP_CHAT)
}
"added_by_username" -> {
setPage("search")
setSource(FriendAddSource.USERNAME)
}
"added_by_qr_code" -> {
setPage("scan_snapcode")
setSource(FriendAddSource.QR_CODE)
}
"added_by_mention" -> {
setPage("context_card")
setSource(FriendAddSource.MENTION)
}
"added_by_community" -> {
setPage("profile")
setSource(FriendAddSource.COMMUNITY)
}
"added_by_quick_add" -> {
setPage("add_friends_button_on_top_bar_on_friends_feed")
setSource(FriendAddSource.SUGGESTED)
}
}
}
override fun init() {
onNextActivityCreate {
context.mappings.useMapper(FriendRelationshipChangerMapper::class) {
classReference.get()?.hookConstructor(HookStage.AFTER) { param ->
friendRelationshipChangerInstance = param.thisObject()
}
}.toByteArray()
}
context.event.subscribe(UnaryCallEvent::class) { event ->
if (event.uri != "/snapchat.friending.server.FriendAction/AddFriends") return@subscribe
val spoofedSource = context.config.experimental.addFriendSourceSpoof.getNullable() ?: return@subscribe
event.buffer = ProtoEditor(event.buffer).apply {
edit {
fun setPage(value: String) {
remove(1)
addString(1, value)
}
editEach(2) {
remove(3) // remove suggestion token
fun setSource(source: FriendAddSource) {
remove(2)
addVarInt(2, source.id)
}
when (spoofedSource) {
"added_by_group_chat" -> {
setPage("group_profile")
setSource(FriendAddSource.GROUP_CHAT)
}
"added_by_username" -> {
setPage("search")
setSource(FriendAddSource.USERNAME)
}
"added_by_qr_code" -> {
setPage("scan_snapcode")
setSource(FriendAddSource.QR_CODE)
}
"added_by_mention" -> {
setPage("context_card")
setSource(FriendAddSource.MENTION)
}
"added_by_community" -> {
setPage("profile")
setSource(FriendAddSource.COMMUNITY)
}
"added_by_quick_add" -> {
setPage("add_friends_button_on_top_bar_on_friends_feed")
setSource(FriendAddSource.SUGGESTED)
}
}
}
}
}.toByteArray()
}
}
}
}

View File

@@ -31,7 +31,6 @@ import me.rhunk.snapenhance.common.ui.AppMaterialTheme
import me.rhunk.snapenhance.common.ui.createComposeView
import me.rhunk.snapenhance.core.event.events.impl.ActivityResultEvent
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.ui.addForegroundDrawable
import me.rhunk.snapenhance.core.ui.children
import me.rhunk.snapenhance.core.ui.removeForegroundDrawable
@@ -39,7 +38,7 @@ import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hook
import kotlin.random.Random
class AppLock : Feature("AppLock", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
class AppLock : Feature("AppLock") {
private var isUnlockRequested = false
private val rootContentView get() = context.mainActivity!!.findViewById<FrameLayout>(android.R.id.content)
@@ -122,32 +121,34 @@ class AppLock : Feature("AppLock", loadParams = FeatureLoadParams.ACTIVITY_CREAT
}
}
override fun onActivityCreate() {
override fun init() {
if (context.config.experimental.appLock.globalState != true) return
Activity::class.java.apply {
if (context.config.experimental.appLock.lockOnResume.get()) {
hook("onResume", HookStage.BEFORE) { param ->
if (param.thisObject<Activity>().packageName != Constants.SNAPCHAT_PACKAGE_NAME) return@hook
if (isUnlockRequested) return@hook
lock(prompt = true)
}
hook("onPause", HookStage.BEFORE) { param ->
if (param.thisObject<Activity>().packageName != Constants.SNAPCHAT_PACKAGE_NAME) return@hook
if (isUnlockRequested) return@hook
hideRootView()
onNextActivityCreate {
Activity::class.java.apply {
if (context.config.experimental.appLock.lockOnResume.get()) {
hook("onResume", HookStage.BEFORE) { param ->
if (param.thisObject<Activity>().packageName != Constants.SNAPCHAT_PACKAGE_NAME) return@hook
if (isUnlockRequested) return@hook
lock(prompt = true)
}
hook("onPause", HookStage.BEFORE) { param ->
if (param.thisObject<Activity>().packageName != Constants.SNAPCHAT_PACKAGE_NAME) return@hook
if (isUnlockRequested) return@hook
hideRootView()
}
}
}
}
context.event.subscribe(ActivityResultEvent::class) { event ->
if (event.requestCode != requestCode) return@subscribe
if (event.resultCode == Activity.RESULT_OK) {
unlock()
return@subscribe
context.event.subscribe(ActivityResultEvent::class) { event ->
if (event.requestCode != requestCode) return@subscribe
if (event.resultCode == Activity.RESULT_OK) {
unlock()
return@subscribe
}
lock(prompt = false)
}
lock(prompt = false)
lock()
}
lock()
}
}

View File

@@ -11,7 +11,6 @@ import me.rhunk.snapenhance.common.data.ContentType
import me.rhunk.snapenhance.common.data.MessageUpdate
import me.rhunk.snapenhance.common.data.MessagingRuleType
import me.rhunk.snapenhance.core.event.events.impl.BuildMessageEvent
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.features.MessagingRuleFeature
import me.rhunk.snapenhance.core.features.impl.messaging.Messaging
import java.util.concurrent.atomic.AtomicInteger
@@ -19,7 +18,7 @@ import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlin.random.Random
class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.AUTO_OPEN_SNAPS, loadParams = FeatureLoadParams.INIT_SYNC) {
class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.AUTO_OPEN_SNAPS) {
private val snapQueue = MutableSharedFlow<Pair<String, Long>>()
private var snapQueueSize = AtomicInteger(0)
private val openedSnaps = mutableListOf<Long>()

View File

@@ -12,13 +12,12 @@ import me.rhunk.snapenhance.common.util.protobuf.ProtoReader
import me.rhunk.snapenhance.core.event.events.impl.NetworkApiRequestEvent
import me.rhunk.snapenhance.core.event.events.impl.UnaryCallEvent
import me.rhunk.snapenhance.core.features.BridgeFileFeature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.ui.triggerRootCloseTouchEvent
import java.io.InputStreamReader
import java.nio.ByteBuffer
import java.util.UUID
class BestFriendPinning: BridgeFileFeature("Best Friend Pinning", InternalFileHandleType.PINNED_BEST_FRIEND, loadParams = FeatureLoadParams.INIT_SYNC) {
class BestFriendPinning: BridgeFileFeature("Best Friend Pinning", InternalFileHandleType.PINNED_BEST_FRIEND) {
private fun updatePinnedBestFriendStatus() {
lines().firstOrNull()?.trim()?.let {
context.database.updatePinnedBestFriendStatus(it.substring(0, 36), "number_one_bf_for_two_months")

View File

@@ -25,7 +25,6 @@ import me.rhunk.snapenhance.common.util.protobuf.ProtoReader
import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent
import me.rhunk.snapenhance.core.event.events.impl.UnaryCallEvent
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.util.RandomWalking
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hook
@@ -44,7 +43,7 @@ data class FriendLocation(
val localityPieces: List<String>
)
class BetterLocation : Feature("Better Location", loadParams = FeatureLoadParams.INIT_SYNC) {
class BetterLocation : Feature("Better Location") {
private val locationHistory = mutableMapOf<String, FriendLocation>()
private val walkRadius by lazy {

View File

@@ -5,7 +5,6 @@ import me.rhunk.snapenhance.common.util.TranscriptApi
import me.rhunk.snapenhance.common.util.protobuf.ProtoEditor
import me.rhunk.snapenhance.core.event.events.impl.BuildMessageEvent
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.util.dataBuilder
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hook
@@ -15,62 +14,65 @@ import okhttp3.RequestBody.Companion.toRequestBody
import java.lang.reflect.Method
import java.nio.ByteBuffer
class BetterTranscript: Feature("Better Transcript", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
class BetterTranscript: Feature("Better Transcript") {
val transcriptApi by lazy { TranscriptApi() }
override fun onActivityCreate() {
override fun init() {
if (context.config.experimental.betterTranscript.globalState != true) return
val config = context.config.experimental.betterTranscript
val preferredTranscriptionLang = config.preferredTranscriptionLang.getNullable()?.takeIf {
it.isNotBlank()
}
if (config.forceTranscription.get()) {
context.event.subscribe(BuildMessageEvent::class, priority = 104) { event ->
if (event.message.messageContent?.contentType != ContentType.NOTE) return@subscribe
event.message.messageContent!!.content = ProtoEditor(event.message.messageContent!!.content!!).apply {
edit(6, 1) {
if (firstOrNull(3) == null) {
addString(3, context.getConfigLocale())
onNextActivityCreate {
val config = context.config.experimental.betterTranscript
val preferredTranscriptionLang = config.preferredTranscriptionLang.getNullable()?.takeIf {
it.isNotBlank()
}
if (config.forceTranscription.get()) {
context.event.subscribe(BuildMessageEvent::class, priority = 104) { event ->
if (event.message.messageContent?.contentType != ContentType.NOTE) return@subscribe
event.message.messageContent!!.content = ProtoEditor(event.message.messageContent!!.content!!).apply {
edit(6, 1) {
if (firstOrNull(3) == null) {
addString(3, context.getConfigLocale())
}
}
}
}.toByteArray()
}
}
findClass("com.snapchat.client.voiceml.IVoiceMLSDK\$CppProxy").hook("asrTranscribe", HookStage.BEFORE) { param ->
if (config.enhancedTranscript.get()) {
val buffer = param.arg<ByteBuffer>(2).let {
it.rewind()
ByteArray(it.remaining()).also { it1 -> it.get(it1); it.rewind() }
}.toByteArray()
}
val result = runCatching {
transcriptApi.transcribe(
buffer.toRequestBody(),
lang = config.preferredTranscriptionLang.getNullable()?.takeIf {
it.isNotBlank()
}?.uppercase()
)
}.onFailure {
context.log.error("Failed to transcribe audio", it)
context.shortToast("Failed to transcribe audio! Check logcat for more details.")
}.getOrNull()
param.setResult(
(param.method() as Method).returnType.dataBuilder {
set("mError", result == null)
set("mNlpResponses", ArrayList<Any>())
set("mWordInfo", ArrayList<Any>())
set("mTranscription", result)
}
)
return@hook
}
preferredTranscriptionLang?.lowercase()?.let {
val asrConfig = param.arg<Any>(1)
asrConfig.getObjectFieldOrNull("mBaseConfig")?.apply {
setObjectField("mLanguageModel", it)
setObjectField("mUiLanguage", it)
findClass("com.snapchat.client.voiceml.IVoiceMLSDK\$CppProxy").hook("asrTranscribe", HookStage.BEFORE) { param ->
if (config.enhancedTranscript.get()) {
val buffer = param.arg<ByteBuffer>(2).let {
it.rewind()
ByteArray(it.remaining()).also { it1 -> it.get(it1); it.rewind() }
}
val result = runCatching {
transcriptApi.transcribe(
buffer.toRequestBody(),
lang = config.preferredTranscriptionLang.getNullable()?.takeIf {
it.isNotBlank()
}?.uppercase()
)
}.onFailure {
context.log.error("Failed to transcribe audio", it)
context.shortToast("Failed to transcribe audio! Check logcat for more details.")
}.getOrNull()
param.setResult(
(param.method() as Method).returnType.dataBuilder {
set("mError", result == null)
set("mNlpResponses", ArrayList<Any>())
set("mWordInfo", ArrayList<Any>())
set("mTranscription", result)
}
)
return@hook
}
preferredTranscriptionLang?.lowercase()?.let {
val asrConfig = param.arg<Any>(1)
asrConfig.getObjectFieldOrNull("mBaseConfig")?.apply {
setObjectField("mLanguageModel", it)
setObjectField("mUiLanguage", it)
}
}
}
}

View File

@@ -8,7 +8,6 @@ import kotlinx.coroutines.withTimeoutOrNull
import me.rhunk.snapenhance.common.data.download.AudioStreamFormat
import me.rhunk.snapenhance.common.data.download.MediaDownloadSource
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.features.impl.downloader.MediaDownloader
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hook
@@ -23,7 +22,7 @@ import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
class CallRecorder : Feature("Call Recorder", loadParams = FeatureLoadParams.INIT_SYNC) {
class CallRecorder : Feature("Call Recorder") {
private val httpServer = HttpServer(
timeout = Integer.MAX_VALUE
)

View File

@@ -25,7 +25,6 @@ import me.rhunk.snapenhance.common.ui.AppMaterialTheme
import me.rhunk.snapenhance.common.ui.createComposeAlertDialog
import me.rhunk.snapenhance.common.ui.createComposeView
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.features.impl.downloader.MediaDownloader
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.Hooker
@@ -36,7 +35,7 @@ import java.lang.reflect.Proxy
import kotlin.math.absoluteValue
import kotlin.random.Random
class ComposerHooks: Feature("ComposerHooks", loadParams = FeatureLoadParams.INIT_SYNC) {
class ComposerHooks: Feature("ComposerHooks") {
private val config by lazy { context.config.experimental.nativeHooks.composerHooks }
private val getImportsFunctionName = Random.nextLong().absoluteValue.toString(16)

View File

@@ -2,10 +2,9 @@ package me.rhunk.snapenhance.core.features.impl.experiments
import me.rhunk.snapenhance.core.event.events.impl.UnaryCallEvent
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import java.nio.ByteBuffer
class ContextMenuFix: Feature("Context Menu Fix", loadParams = FeatureLoadParams.INIT_SYNC) {
class ContextMenuFix: Feature("Context Menu Fix") {
override fun init() {
if (!context.config.experimental.contextMenuFix.get()) return
context.event.subscribe(UnaryCallEvent::class) { event ->

View File

@@ -5,13 +5,12 @@ import me.rhunk.snapenhance.common.util.protobuf.ProtoReader
import me.rhunk.snapenhance.common.util.protobuf.ProtoWriter
import me.rhunk.snapenhance.core.event.events.impl.BuildMessageEvent
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.features.impl.messaging.Messaging
import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper
import me.rhunk.snapenhance.core.wrapper.impl.Message
import me.rhunk.snapenhance.core.wrapper.impl.MessageContent
class ConvertMessageLocally : Feature("Convert Message Edit", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
class ConvertMessageLocally : Feature("Convert Message Edit") {
private val messageCache = mutableMapOf<Long, MessageContent>()
private fun dispatchMessageEdit(message: Message, restore: Boolean = false) {
@@ -64,11 +63,13 @@ class ConvertMessageLocally : Feature("Convert Message Edit", loadParams = Featu
}.show()
}
override fun onActivityCreate() {
context.event.subscribe(BuildMessageEvent::class, priority = 2) {
val clientMessageId = it.message.messageDescriptor?.messageId ?: return@subscribe
if (!messageCache.containsKey(clientMessageId)) return@subscribe
it.message.messageContent = messageCache[clientMessageId]
override fun init() {
onNextActivityCreate {
context.event.subscribe(BuildMessageEvent::class, priority = 2) {
val clientMessageId = it.message.messageDescriptor?.messageId ?: return@subscribe
if (!messageCache.containsKey(clientMessageId)) return@subscribe
it.message.messageContent = messageCache[clientMessageId]
}
}
}
}

View File

@@ -7,12 +7,11 @@ import android.net.Network
import android.net.NetworkCapabilities
import android.os.Build
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.util.LSPatchUpdater
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hook
class DeviceSpooferHook: Feature("device_spoofer", loadParams = FeatureLoadParams.INIT_SYNC) {
class DeviceSpooferHook: Feature("device_spoofer") {
private fun hookInstallerPackageName() {
context.androidContext.packageManager::class.java.hook("getInstallerPackageName", HookStage.BEFORE) { param ->
param.setResult("com.android.vending")

View File

@@ -33,7 +33,6 @@ import me.rhunk.snapenhance.core.event.events.impl.BindViewEvent
import me.rhunk.snapenhance.core.event.events.impl.BuildMessageEvent
import me.rhunk.snapenhance.core.event.events.impl.NativeUnaryCallEvent
import me.rhunk.snapenhance.core.event.events.impl.SendMessageWithContentEvent
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.features.MessagingRuleFeature
import me.rhunk.snapenhance.core.features.impl.ui.ConversationToolbox
import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper
@@ -55,8 +54,7 @@ import kotlin.random.Random
class EndToEndEncryption : MessagingRuleFeature(
"EndToEndEncryption",
MessagingRuleType.E2E_ENCRYPTION,
loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC or FeatureLoadParams.INIT_SYNC or FeatureLoadParams.INIT_ASYNC
MessagingRuleType.E2E_ENCRYPTION
) {
val isEnabled get() = context.config.experimental.e2eEncryption.globalState == true
private val e2eeInterface by lazyBridge { context.bridgeClient.getE2eeInterface() }
@@ -174,96 +172,223 @@ class EndToEndEncryption : MessagingRuleFeature(
}
@SuppressLint("SetTextI18n", "DiscouragedApi")
override fun onActivityCreate() {
override fun init() {
if (!isEnabled) return
context.feature(ConversationToolbox::class).addComposable(translation["confirmation_dialogs.title"], filter = {
context.database.getDMOtherParticipant(it) != null
}) { dialog, conversationId ->
val friendId = remember {
context.database.getDMOtherParticipant(conversationId)
} ?: return@addComposable
val fingerprint = remember {
runCatching {
e2eeInterface.getSecretFingerprint(friendId)
}.getOrNull()
}
if (fingerprint != null) {
Text(translation.format("toolbox.shared_key_fingerprint", "fingerprint" to fingerprint))
} else {
Text(translation["toolbox.no_shared_key"])
}
Spacer(modifier = Modifier.height(10.dp))
Button(onClick = {
dialog.dismiss()
warnKeyOverwrite(friendId) {
askForKeys(conversationId)
context.mappings.useMapper(CallbackMapper::class) {
callbacks.getClass("ConversationManagerDelegate")?.hook("onSendComplete", HookStage.BEFORE) { param ->
val sendMessageResult = param.arg<Any>(0)
val messageDestinations = MessageDestinations(sendMessageResult.getObjectField("mCompletedDestinations") ?: return@hook)
if (messageDestinations.mPhoneNumbers?.isNotEmpty() == true || messageDestinations.stories?.isNotEmpty() == true) return@hook
val completedConversationDestinations = sendMessageResult.getObjectField("mCompletedConversationDestinations") as? ArrayList<*> ?: return@hook
val messageIds = completedConversationDestinations.filter { getState(SnapUUID(it.getObjectField("mConversationId")).toString()) }.mapNotNull {
it.getObjectFieldOrNull("mMessageId") as? Long
}
}) {
Text(translation["toolbox.initiate_exchange_button"])
encryptedMessages.addAll(messageIds)
}
}
val encryptedMessageIndicator by context.config.experimental.e2eEncryption.encryptedMessageIndicator
context.event.subscribe(BuildMessageEvent::class, priority = 0) { event ->
val message = event.message
val conversationId = message.messageDescriptor!!.conversationId.toString()
val isMessageCommitted = message.messageState == MessageState.COMMITTED
messageHook(
conversationId = conversationId,
messageId = message.messageDescriptor!!.messageId!!,
senderId = message.senderId.toString(),
messageContent = message.messageContent!!,
committed = isMessageCommitted
)
val specialCard = Random.nextLong().toString(16)
context.event.subscribe(BindViewEvent::class) { event ->
event.chatMessage { conversationId, messageId ->
val viewGroup = event.view.parent as? ViewGroup ?: return@subscribe
viewGroup.findViewWithTag<View>(specialCard)?.also {
viewGroup.removeView(it)
message.messageContent!!.instanceNonNull()
.getObjectField("mQuotedMessage")
?.getObjectField("mContent")
?.also { quotedMessage ->
messageHook(
conversationId = conversationId,
messageId = quotedMessage.getObjectField("mMessageId")?.toString()?.toLong() ?: return@also,
senderId = SnapUUID(quotedMessage.getObjectField("mSenderId")).toString(),
messageContent = MessageContent(quotedMessage),
committed = isMessageCommitted
)
}
}
if (encryptedMessageIndicator) {
viewGroup.removeForegroundDrawable("encryptedMessage")
onNextActivityCreate(defer = true) {
context.feature(ConversationToolbox::class).addComposable(translation["confirmation_dialogs.title"], filter = {
context.database.getDMOtherParticipant(it) != null
}) { dialog, conversationId ->
val friendId = remember {
context.database.getDMOtherParticipant(conversationId)
} ?: return@addComposable
val fingerprint = remember {
runCatching {
e2eeInterface.getSecretFingerprint(friendId)
}.getOrNull()
}
if (fingerprint != null) {
Text(translation.format("toolbox.shared_key_fingerprint", "fingerprint" to fingerprint))
} else {
Text(translation["toolbox.no_shared_key"])
}
Spacer(modifier = Modifier.height(10.dp))
Button(onClick = {
dialog.dismiss()
warnKeyOverwrite(friendId) {
askForKeys(conversationId)
}
}) {
Text(translation["toolbox.initiate_exchange_button"])
}
}
if (encryptedMessages.contains(messageId.toLong())) {
viewGroup.addForegroundDrawable("encryptedMessage", ShapeDrawable(object: Shape() {
override fun draw(canvas: Canvas, paint: Paint) {
paint.textSize = 20f
canvas.drawText("\uD83D\uDD12", 0f, canvas.height / 2f, paint)
val encryptedMessageIndicator by context.config.experimental.e2eEncryption.encryptedMessageIndicator
val specialCard = Random.nextLong().toString(16)
context.event.subscribe(BindViewEvent::class) { event ->
event.chatMessage { conversationId, messageId ->
val viewGroup = event.view.parent as? ViewGroup ?: return@subscribe
viewGroup.findViewWithTag<View>(specialCard)?.also {
viewGroup.removeView(it)
}
if (encryptedMessageIndicator) {
viewGroup.removeForegroundDrawable("encryptedMessage")
if (encryptedMessages.contains(messageId.toLong())) {
viewGroup.addForegroundDrawable("encryptedMessage", ShapeDrawable(object: Shape() {
override fun draw(canvas: Canvas, paint: Paint) {
paint.textSize = 20f
canvas.drawText("\uD83D\uDD12", 0f, canvas.height / 2f, paint)
}
}))
}
}
val secret = secretResponses[messageId.toLong()]
val publicKey = pkRequests[messageId.toLong()]
if (publicKey != null || secret != null) {
viewGroup.addView(createComposeView(context.mainActivity!!) {
Card(
modifier = Modifier.fillMaxWidth().padding(8.dp),
onClick = {
if (publicKey != null) {
handlePublicKeyRequest(conversationId, publicKey)
}
if (secret != null) {
handleSecretResponse(conversationId, secret)
}
}
) {
Box(
modifier = Modifier.fillMaxWidth().padding(5.dp),
contentAlignment = Alignment.Center
) {
if (publicKey != null) {
Text(translation["accept_public_key_button"])
}
if (secret != null) {
Text(translation["accept_secret_button"])
}
}
}
}))
}.apply {
tag = specialCard
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
)
})
}
}
}
}
val secret = secretResponses[messageId.toLong()]
val publicKey = pkRequests[messageId.toLong()]
defer {
val forceMessageEncryption by context.config.experimental.e2eEncryption.forceMessageEncryption
if (publicKey != null || secret != null) {
viewGroup.addView(createComposeView(context.mainActivity!!) {
Card(
modifier = Modifier.fillMaxWidth().padding(8.dp),
onClick = {
if (publicKey != null) {
handlePublicKeyRequest(conversationId, publicKey)
}
if (secret != null) {
handleSecretResponse(conversationId, secret)
}
}
) {
Box(
modifier = Modifier.fillMaxWidth().padding(5.dp),
contentAlignment = Alignment.Center
) {
if (publicKey != null) {
Text(translation["accept_public_key_button"])
}
if (secret != null) {
Text(translation["accept_secret_button"])
}
}
context.mappings.useMapper(CallbackMapper::class) {
callbacks.getClass("UploadDelegate")?.hook("uploadMedia", HookStage.BEFORE) { param ->
val messageDestinations = MessageDestinations(param.arg(1))
val uploadCallback = param.arg<Any>(2)
val e2eeConversations = messageDestinations.getEndToEndConversations()
if (e2eeConversations.isEmpty()) return@hook
if (messageDestinations.conversations!!.size != e2eeConversations.size || messageDestinations.stories?.isNotEmpty() == true) {
context.log.debug("skipping encryption")
return@hook
}
Hooker.hookObjectMethod(uploadCallback::class.java, uploadCallback, "onUploadFinished", HookStage.BEFORE) { methodParam ->
val messageContent = MessageContent(methodParam.arg(1))
runCatching {
messageContent.content = ProtoWriter().apply {
writeEncryptedMessage(e2eeConversations.map { getE2EParticipants(it) }.flatten().distinct(), messageContent.content!!)
}.toByteArray()
}.onFailure {
context.log.error("Failed to encrypt message", it)
context.longToast(translation["encryption_failed_toast"])
}
}.apply {
tag = specialCard
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
)
})
}
}
}
// trick to disable fidelius encryption
context.event.subscribe(SendMessageWithContentEvent::class) { event ->
val messageContent = event.messageContent
val destinations = event.destinations
val e2eeConversations = destinations.getEndToEndConversations().takeIf { it.isNotEmpty() } ?: return@subscribe
if (e2eeConversations.size != destinations.conversations!!.size || destinations.stories?.isNotEmpty() == true) {
if (!forceMessageEncryption) return@subscribe
context.longToast(translation["unencrypted_conversation_send_failure_toast"])
event.canceled = true
return@subscribe
}
if (!NativeLib.initialized) {
context.longToast(translation["native_hooks_send_failure_toast"])
event.canceled = true
return@subscribe
}
event.addInvokeLater {
if (event.messageContent.localMediaReferences?.isEmpty() == true) {
runCatching {
event.messageContent.content = ProtoWriter().apply {
writeEncryptedMessage(e2eeConversations.map { getE2EParticipants(it) }.flatten().distinct(), messageContent.content!!)
}.toByteArray()
}.onFailure {
context.log.error("Failed to encrypt message", it)
context.longToast(translation["encryption_failed_toast"])
}
}
if (event.messageContent.contentType == ContentType.SNAP) {
event.messageContent.contentType = ContentType.EXTERNAL_MEDIA
}
}
}
context.event.subscribe(NativeUnaryCallEvent::class) { event ->
if (event.uri != "/messagingcoreservice.MessagingCoreService/CreateContentMessage") return@subscribe
val protoReader = ProtoReader(event.buffer)
val messageReader = protoReader.followPath(4) ?: return@subscribe
if (messageReader.getVarInt(4, 2, 1, 5) == 1L) {
event.buffer = ProtoEditor(event.buffer).apply {
edit(4) {
remove(2)
addVarInt(2, ContentType.SNAP.id)
context.log.verbose("fixed snap content type")
}
}.toByteArray()
}
}
}
@@ -440,135 +565,5 @@ class EndToEndEncryption : MessagingRuleFeature(
return conversations!!.filter { getState(it.toString()) && getE2EParticipants(it.toString()).isNotEmpty() }.map { it.toString() }
}
override fun asyncInit() {
if (!isEnabled) return
val forceMessageEncryption by context.config.experimental.e2eEncryption.forceMessageEncryption
context.mappings.useMapper(CallbackMapper::class) {
callbacks.getClass("UploadDelegate")?.hook("uploadMedia", HookStage.BEFORE) { param ->
val messageDestinations = MessageDestinations(param.arg(1))
val uploadCallback = param.arg<Any>(2)
val e2eeConversations = messageDestinations.getEndToEndConversations()
if (e2eeConversations.isEmpty()) return@hook
if (messageDestinations.conversations!!.size != e2eeConversations.size || messageDestinations.stories?.isNotEmpty() == true) {
context.log.debug("skipping encryption")
return@hook
}
Hooker.hookObjectMethod(uploadCallback::class.java, uploadCallback, "onUploadFinished", HookStage.BEFORE) { methodParam ->
val messageContent = MessageContent(methodParam.arg(1))
runCatching {
messageContent.content = ProtoWriter().apply {
writeEncryptedMessage(e2eeConversations.map { getE2EParticipants(it) }.flatten().distinct(), messageContent.content!!)
}.toByteArray()
}.onFailure {
context.log.error("Failed to encrypt message", it)
context.longToast(translation["encryption_failed_toast"])
}
}
}
}
// trick to disable fidelius encryption
context.event.subscribe(SendMessageWithContentEvent::class) { event ->
val messageContent = event.messageContent
val destinations = event.destinations
val e2eeConversations = destinations.getEndToEndConversations().takeIf { it.isNotEmpty() } ?: return@subscribe
if (e2eeConversations.size != destinations.conversations!!.size || destinations.stories?.isNotEmpty() == true) {
if (!forceMessageEncryption) return@subscribe
context.longToast(translation["unencrypted_conversation_send_failure_toast"])
event.canceled = true
return@subscribe
}
if (!NativeLib.initialized) {
context.longToast(translation["native_hooks_send_failure_toast"])
event.canceled = true
return@subscribe
}
event.addInvokeLater {
if (event.messageContent.localMediaReferences?.isEmpty() == true) {
runCatching {
event.messageContent.content = ProtoWriter().apply {
writeEncryptedMessage(e2eeConversations.map { getE2EParticipants(it) }.flatten().distinct(), messageContent.content!!)
}.toByteArray()
}.onFailure {
context.log.error("Failed to encrypt message", it)
context.longToast(translation["encryption_failed_toast"])
}
}
if (event.messageContent.contentType == ContentType.SNAP) {
event.messageContent.contentType = ContentType.EXTERNAL_MEDIA
}
}
}
context.event.subscribe(NativeUnaryCallEvent::class) { event ->
if (event.uri != "/messagingcoreservice.MessagingCoreService/CreateContentMessage") return@subscribe
val protoReader = ProtoReader(event.buffer)
val messageReader = protoReader.followPath(4) ?: return@subscribe
if (messageReader.getVarInt(4, 2, 1, 5) == 1L) {
event.buffer = ProtoEditor(event.buffer).apply {
edit(4) {
remove(2)
addVarInt(2, ContentType.SNAP.id)
context.log.verbose("fixed snap content type")
}
}.toByteArray()
}
}
}
override fun init() {
if (!isEnabled) return
context.mappings.useMapper(CallbackMapper::class) {
callbacks.getClass("ConversationManagerDelegate")?.hook("onSendComplete", HookStage.BEFORE) { param ->
val sendMessageResult = param.arg<Any>(0)
val messageDestinations = MessageDestinations(sendMessageResult.getObjectField("mCompletedDestinations") ?: return@hook)
if (messageDestinations.mPhoneNumbers?.isNotEmpty() == true || messageDestinations.stories?.isNotEmpty() == true) return@hook
val completedConversationDestinations = sendMessageResult.getObjectField("mCompletedConversationDestinations") as? ArrayList<*> ?: return@hook
val messageIds = completedConversationDestinations.filter { getState(SnapUUID(it.getObjectField("mConversationId")).toString()) }.mapNotNull {
it.getObjectFieldOrNull("mMessageId") as? Long
}
encryptedMessages.addAll(messageIds)
}
}
context.event.subscribe(BuildMessageEvent::class, priority = 0) { event ->
val message = event.message
val conversationId = message.messageDescriptor!!.conversationId.toString()
val isMessageCommitted = message.messageState == MessageState.COMMITTED
messageHook(
conversationId = conversationId,
messageId = message.messageDescriptor!!.messageId!!,
senderId = message.senderId.toString(),
messageContent = message.messageContent!!,
committed = isMessageCommitted
)
message.messageContent!!.instanceNonNull()
.getObjectField("mQuotedMessage")
?.getObjectField("mContent")
?.also { quotedMessage ->
messageHook(
conversationId = conversationId,
messageId = quotedMessage.getObjectField("mMessageId")?.toString()?.toLong() ?: return@also,
senderId = SnapUUID(quotedMessage.getObjectField("mSenderId")).toString(),
messageContent = MessageContent(quotedMessage),
committed = isMessageCommitted
)
}
}
}
override fun getRuleState() = RuleState.WHITELIST
}

View File

@@ -1,22 +1,23 @@
package me.rhunk.snapenhance.core.features.impl.experiments
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hookConstructor
import me.rhunk.snapenhance.mapper.impl.StoryBoostStateMapper
class InfiniteStoryBoost : Feature("InfiniteStoryBoost", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) {
override fun asyncOnActivityCreate() {
class InfiniteStoryBoost : Feature("InfiniteStoryBoost") {
override fun init() {
if (!context.config.experimental.infiniteStoryBoost.get()) return
context.mappings.useMapper(StoryBoostStateMapper::class) {
classReference.get()?.hookConstructor(HookStage.BEFORE) { param ->
val startTimeMillis = param.arg<Long>(1)
//reset timestamp if it's more than 24 hours
if (System.currentTimeMillis() - startTimeMillis > 86400000) {
param.setArg(1, 0)
param.setArg(2, 0)
onNextActivityCreate(defer = true) {
context.mappings.useMapper(StoryBoostStateMapper::class) {
classReference.get()?.hookConstructor(HookStage.BEFORE) { param ->
val startTimeMillis = param.arg<Long>(1)
//reset timestamp if it's more than 24 hours
if (System.currentTimeMillis() - startTimeMillis > 86400000) {
param.setArg(1, 0)
param.setArg(2, 0)
}
}
}
}

View File

@@ -31,7 +31,6 @@ import me.rhunk.snapenhance.common.util.ktx.getTypeArguments
import me.rhunk.snapenhance.core.event.events.impl.ActivityResultEvent
import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper
import me.rhunk.snapenhance.core.util.dataBuilder
import me.rhunk.snapenhance.core.util.hook.HookStage
@@ -41,198 +40,200 @@ import java.io.InputStream
import java.lang.reflect.Method
import kotlin.random.Random
class MediaFilePicker : Feature("Media File Picker", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
class MediaFilePicker : Feature("Media File Picker") {
var lastMediaDuration: Long? = null
private set
@SuppressLint("Recycle")
override fun onActivityCreate() {
override fun init() {
if (!context.config.experimental.mediaFilePicker.get()) return
lateinit var chatMediaDrawerActionHandler: Any
lateinit var sendItemsMethod: Method
onNextActivityCreate(defer = true) {
lateinit var chatMediaDrawerActionHandler: Any
lateinit var sendItemsMethod: Method
findClass("com.snap.composer.memories.ChatMediaDrawer").genericSuperclass?.getTypeArguments()?.getOrNull(1)?.apply {
methods.first {
it.parameterTypes.size == 1 && it.parameterTypes[0].name.endsWith("ChatMediaDrawerActionHandler")
}.also { method ->
sendItemsMethod = method.parameterTypes[0].methods.first { it.name == "sendItems" }
}.hook(HookStage.AFTER) {
chatMediaDrawerActionHandler = it.arg(0)
findClass("com.snap.composer.memories.ChatMediaDrawer").genericSuperclass?.getTypeArguments()?.getOrNull(1)?.apply {
methods.first {
it.parameterTypes.size == 1 && it.parameterTypes[0].name.endsWith("ChatMediaDrawerActionHandler")
}.also { method ->
sendItemsMethod = method.parameterTypes[0].methods.first { it.name == "sendItems" }
}.hook(HookStage.AFTER) {
chatMediaDrawerActionHandler = it.arg(0)
}
}
}
var requestCode: Int? = null
var firstVideoId: Long? = null
var mediaInputStream: InputStream? = null
var requestCode: Int? = null
var firstVideoId: Long? = null
var mediaInputStream: InputStream? = null
ContentResolver::class.java.apply {
hook("query", HookStage.AFTER) { param ->
val uri = param.arg<Uri>(0)
if (!uri.toString().endsWith(firstVideoId.toString())) return@hook
ContentResolver::class.java.apply {
hook("query", HookStage.AFTER) { param ->
val uri = param.arg<Uri>(0)
if (!uri.toString().endsWith(firstVideoId.toString())) return@hook
param.setResult(object: CursorWrapper(param.getResult() as Cursor) {
override fun getLong(columnIndex: Int): Long {
if (getColumnName(columnIndex) == "duration") {
return lastMediaDuration ?: -1
param.setResult(object: CursorWrapper(param.getResult() as Cursor) {
override fun getLong(columnIndex: Int): Long {
if (getColumnName(columnIndex) == "duration") {
return lastMediaDuration ?: -1
}
return super.getLong(columnIndex)
}
return super.getLong(columnIndex)
})
}
hook("openInputStream", HookStage.BEFORE) { param ->
val uri = param.arg<Uri>(0)
if (uri.toString().endsWith(firstVideoId.toString())) {
param.setResult(mediaInputStream)
mediaInputStream = null
}
}
}
context.event.subscribe(ActivityResultEvent::class) { event ->
if (event.requestCode != requestCode || event.resultCode != Activity.RESULT_OK) return@subscribe
requestCode = null
firstVideoId = context.androidContext.contentResolver.query(
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
arrayOf(MediaStore.Video.Media._ID),
null,
null,
"${MediaStore.Video.Media.DATE_TAKEN} DESC"
)?.use { cursor ->
if (cursor.moveToFirst()) {
cursor.getLongOrNull("_id")
} else {
null
}
}
if (firstVideoId == null) {
context.inAppOverlay.showStatusToast(
Icons.Default.Upload,
"Must have a video in gallery to upload."
)
return@subscribe
}
fun sendMedia() {
sendItemsMethod.invoke(chatMediaDrawerActionHandler, listOf<Any>(), listOf(
sendItemsMethod.genericParameterTypes[1].getTypeArguments().first().dataBuilder {
from("_item") {
set("_cameraRollSource", "Snapchat")
set("_contentUri", "")
set("_durationMs", 0.0)
set("_disabled", false)
set("_imageRotation", 0.0)
set("_width", 1080.0)
set("_height", 1920.0)
set("_timestampMs", System.currentTimeMillis().toDouble())
from("_itemId") {
set("_itemId", firstVideoId.toString())
set("_type", "VIDEO")
}
}
set("_order", 0.0)
}
))
}
fun startConversation(audioOnly: Boolean) {
context.coroutineScope.launch {
lastMediaDuration = MediaPlayer().run {
setDataSource(context.androidContext, event.intent.data!!)
prepare()
duration.toLong().also {
release()
}
}
context.inAppOverlay.showStatusToast(Icons.Default.Crop, "Converting media...", durationMs = 3000)
val pfd = context.bridgeClient.convertMedia(
context.androidContext.contentResolver.openFileDescriptor(event.intent.data!!, "r")!!,
"m4a",
"m4a",
"aac",
if (!audioOnly) "libx264" else null
)
if (pfd == null) {
context.inAppOverlay.showStatusToast(Icons.Default.Error, "Failed to convert media.")
return@launch
}
context.inAppOverlay.showStatusToast(Icons.Default.CheckCircleOutline, "Media converted successfully.")
runCatching {
mediaInputStream = ParcelFileDescriptor.AutoCloseInputStream(pfd)
sendMedia()
}.onFailure {
mediaInputStream = null
context.log.error(it)
context.inAppOverlay.showStatusToast(Icons.Default.Error, "Failed to send media.")
}
}
}
val isAudio = context.androidContext.contentResolver.getType(event.intent.data!!)!!.startsWith("audio/")
if (isAudio || context.config.messaging.galleryMediaSendOverride.getNullable() == null) {
startConversation(isAudio)
return@subscribe
}
ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity!!)
.setTitle("Convert video file")
.setItems(arrayOf("Send as video/audio", "Send as audio only")) { _, which ->
startConversation(which == 1)
}
.setNegativeButton("Cancel") { dialog, _ -> dialog.dismiss() }.show()
}
val buttonTag = Random.nextInt(0, 65535)
context.event.subscribe(AddViewEvent::class) { event ->
if (event.parent.id != context.resources.getId("chat_drawer_container") || !event.view::class.java.name.endsWith("ChatMediaDrawer")) return@subscribe
event.view.addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) {
if (event.parent.findViewWithTag<View>(buttonTag)?.run {
visibility = View.VISIBLE
bringToFront()
} != null) return
event.parent.addView(
createComposeView(context.mainActivity!!) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
FilledIconButton(onClick = {
requestCode = Random.nextInt(0, 65535)
this@MediaFilePicker.context.mainActivity!!.startActivityForResult(
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "video/*"
putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("video/*", "audio/*"))
},
requestCode!!
)
}) {
Icon(Icons.Default.Upload, "Upload media")
}
}
}.apply {
tag = buttonTag
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
}
)
}
override fun onViewDetachedFromWindow(v: View) {
event.parent.findViewWithTag<View>(buttonTag)?.visibility = View.GONE
}
})
}
hook("openInputStream", HookStage.BEFORE) { param ->
val uri = param.arg<Uri>(0)
if (uri.toString().endsWith(firstVideoId.toString())) {
param.setResult(mediaInputStream)
mediaInputStream = null
}
}
}
context.event.subscribe(ActivityResultEvent::class) { event ->
if (event.requestCode != requestCode || event.resultCode != Activity.RESULT_OK) return@subscribe
requestCode = null
firstVideoId = context.androidContext.contentResolver.query(
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
arrayOf(MediaStore.Video.Media._ID),
null,
null,
"${MediaStore.Video.Media.DATE_TAKEN} DESC"
)?.use { cursor ->
if (cursor.moveToFirst()) {
cursor.getLongOrNull("_id")
} else {
null
}
}
if (firstVideoId == null) {
context.inAppOverlay.showStatusToast(
Icons.Default.Upload,
"Must have a video in gallery to upload."
)
return@subscribe
}
fun sendMedia() {
sendItemsMethod.invoke(chatMediaDrawerActionHandler, listOf<Any>(), listOf(
sendItemsMethod.genericParameterTypes[1].getTypeArguments().first().dataBuilder {
from("_item") {
set("_cameraRollSource", "Snapchat")
set("_contentUri", "")
set("_durationMs", 0.0)
set("_disabled", false)
set("_imageRotation", 0.0)
set("_width", 1080.0)
set("_height", 1920.0)
set("_timestampMs", System.currentTimeMillis().toDouble())
from("_itemId") {
set("_itemId", firstVideoId.toString())
set("_type", "VIDEO")
}
}
set("_order", 0.0)
}
))
}
fun startConversation(audioOnly: Boolean) {
context.coroutineScope.launch {
lastMediaDuration = MediaPlayer().run {
setDataSource(context.androidContext, event.intent.data!!)
prepare()
duration.toLong().also {
release()
}
}
context.inAppOverlay.showStatusToast(Icons.Default.Crop, "Converting media...", durationMs = 3000)
val pfd = context.bridgeClient.convertMedia(
context.androidContext.contentResolver.openFileDescriptor(event.intent.data!!, "r")!!,
"m4a",
"m4a",
"aac",
if (!audioOnly) "libx264" else null
)
if (pfd == null) {
context.inAppOverlay.showStatusToast(Icons.Default.Error, "Failed to convert media.")
return@launch
}
context.inAppOverlay.showStatusToast(Icons.Default.CheckCircleOutline, "Media converted successfully.")
runCatching {
mediaInputStream = ParcelFileDescriptor.AutoCloseInputStream(pfd)
sendMedia()
}.onFailure {
mediaInputStream = null
context.log.error(it)
context.inAppOverlay.showStatusToast(Icons.Default.Error, "Failed to send media.")
}
}
}
val isAudio = context.androidContext.contentResolver.getType(event.intent.data!!)!!.startsWith("audio/")
if (isAudio || context.config.messaging.galleryMediaSendOverride.getNullable() == null) {
startConversation(isAudio)
return@subscribe
}
ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity!!)
.setTitle("Convert video file")
.setItems(arrayOf("Send as video/audio", "Send as audio only")) { _, which ->
startConversation(which == 1)
}
.setNegativeButton("Cancel") { dialog, _ -> dialog.dismiss() }.show()
}
val buttonTag = Random.nextInt(0, 65535)
context.event.subscribe(AddViewEvent::class) { event ->
if (event.parent.id != context.resources.getId("chat_drawer_container") || !event.view::class.java.name.endsWith("ChatMediaDrawer")) return@subscribe
event.view.addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) {
if (event.parent.findViewWithTag<View>(buttonTag)?.run {
visibility = View.VISIBLE
bringToFront()
} != null) return
event.parent.addView(
createComposeView(context.mainActivity!!) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
FilledIconButton(onClick = {
requestCode = Random.nextInt(0, 65535)
this@MediaFilePicker.context.mainActivity!!.startActivityForResult(
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "video/*"
putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("video/*", "audio/*"))
},
requestCode!!
)
}) {
Icon(Icons.Default.Upload, "Upload media")
}
}
}.apply {
tag = buttonTag
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
}
)
}
override fun onViewDetachedFromWindow(v: View) {
event.parent.findViewWithTag<View>(buttonTag)?.visibility = View.GONE
}
})
}
}
}

View File

@@ -1,22 +1,23 @@
package me.rhunk.snapenhance.core.features.impl.experiments
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hook
import me.rhunk.snapenhance.mapper.impl.BCryptClassMapper
class MeoPasscodeBypass : Feature("Meo Passcode Bypass", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) {
override fun asyncOnActivityCreate() {
class MeoPasscodeBypass : Feature("Meo Passcode Bypass") {
override fun init() {
if (!context.config.experimental.meoPasscodeBypass.get()) return
context.mappings.useMapper(BCryptClassMapper::class) {
classReference.get()?.hook(
hashMethod.get()!!,
HookStage.BEFORE,
) { param ->
//set the hash to the result of the method
param.setResult(param.arg(1))
onNextActivityCreate(defer = true) {
context.mappings.useMapper(BCryptClassMapper::class) {
classReference.get()?.hook(
hashMethod.get()!!,
HookStage.BEFORE,
) { param ->
//set the hash to the result of the method
param.setResult(param.arg(1))
}
}
}
}

View File

@@ -1,24 +1,25 @@
package me.rhunk.snapenhance.core.features.impl.experiments
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hookConstructor
import me.rhunk.snapenhance.mapper.impl.ScoreUpdateMapper
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.minutes
class NoFriendScoreDelay : Feature("NoFriendScoreDelay", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
override fun onActivityCreate() {
class NoFriendScoreDelay : Feature("NoFriendScoreDelay") {
override fun init() {
if (!context.config.experimental.noFriendScoreDelay.get()) return
context.mappings.useMapper(ScoreUpdateMapper::class) {
classReference.get()?.hookConstructor(HookStage.BEFORE) { param ->
param.args().indexOfFirst {
val longValue = it.toString().toLongOrNull() ?: return@indexOfFirst false
longValue > 30.minutes.inWholeMilliseconds && longValue < 10.days.inWholeMilliseconds
}.takeIf { it != -1 }?.let { index ->
param.setArg(index, 0)
onNextActivityCreate {
context.mappings.useMapper(ScoreUpdateMapper::class) {
classReference.get()?.hookConstructor(HookStage.BEFORE) { param ->
param.args().indexOfFirst {
val longValue = it.toString().toLongOrNull() ?: return@indexOfFirst false
longValue > 30.minutes.inWholeMilliseconds && longValue < 10.days.inWholeMilliseconds
}.takeIf { it != -1 }?.let { index ->
param.setArg(index, 0)
}
}
}
}

View File

@@ -2,11 +2,10 @@ package me.rhunk.snapenhance.core.features.impl.experiments
import android.content.Intent
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hook
class PreventForcedLogout : Feature("Prevent Forced Logout", loadParams = FeatureLoadParams.INIT_SYNC) {
class PreventForcedLogout : Feature("Prevent Forced Logout") {
override fun init() {
if (!context.config.experimental.preventForcedLogout.get()) return
findClass("com.snap.identity.service.ForcedLogoutBroadcastReceiver").hook("onReceive", HookStage.BEFORE) { param ->

View File

@@ -5,7 +5,6 @@ import android.os.FileObserver
import com.google.gson.JsonParser
import me.rhunk.snapenhance.core.event.events.impl.SendMessageWithContentEvent
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hookConstructor
import me.rhunk.snapenhance.core.util.ktx.setObjectField
@@ -13,63 +12,65 @@ import me.rhunk.snapenhance.mapper.impl.DefaultMediaItemMapper
import java.io.File
class BypassVideoLengthRestriction :
Feature("BypassVideoLengthRestriction", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) {
Feature("BypassVideoLengthRestriction") {
private lateinit var fileObserver: FileObserver
override fun asyncOnActivityCreate() {
val mode = context.config.global.bypassVideoLengthRestriction.getNullable()
override fun init() {
onNextActivityCreate(defer = true) {
val mode = context.config.global.bypassVideoLengthRestriction.getNullable()
if (mode == "single") {
//fix black videos when story is posted
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val postedStorySnapFolder =
File(context.androidContext.filesDir, "file_manager/posted_story_snap")
if (mode == "single") {
//fix black videos when story is posted
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val postedStorySnapFolder =
File(context.androidContext.filesDir, "file_manager/posted_story_snap")
fileObserver = (object : FileObserver(postedStorySnapFolder, MOVED_TO) {
override fun onEvent(event: Int, path: String?) {
if (event != MOVED_TO || path?.endsWith("posted_story_snap.2") != true) return
fileObserver.stopWatching()
fileObserver = (object : FileObserver(postedStorySnapFolder, MOVED_TO) {
override fun onEvent(event: Int, path: String?) {
if (event != MOVED_TO || path?.endsWith("posted_story_snap.2") != true) return
fileObserver.stopWatching()
val file = File(postedStorySnapFolder, path)
runCatching {
val fileContent = JsonParser.parseReader(file.reader()).asJsonObject
if (fileContent["timerOrDuration"].asLong < 0) file.delete()
}.onFailure {
context.log.error("Failed to read story metadata file", it)
val file = File(postedStorySnapFolder, path)
runCatching {
val fileContent = JsonParser.parseReader(file.reader()).asJsonObject
if (fileContent["timerOrDuration"].asLong < 0) file.delete()
}.onFailure {
context.log.error("Failed to read story metadata file", it)
}
}
})
context.event.subscribe(SendMessageWithContentEvent::class) { event ->
if (event.destinations.stories!!.isEmpty()) return@subscribe
fileObserver.startWatching()
}
})
}
context.event.subscribe(SendMessageWithContentEvent::class) { event ->
if (event.destinations.stories!!.isEmpty()) return@subscribe
fileObserver.startWatching()
context.mappings.useMapper(DefaultMediaItemMapper::class) {
defaultMediaItem.getAsClass()?.hookConstructor(HookStage.BEFORE) { param ->
//set the video length argument
param.setArg(5, -1L)
}
}
}
context.mappings.useMapper(DefaultMediaItemMapper::class) {
defaultMediaItem.getAsClass()?.hookConstructor(HookStage.BEFORE) { param ->
//set the video length argument
param.setArg(5, -1L)
//TODO: allow split from any source
if (mode == "split") {
// memories grid
context.mappings.useMapper(DefaultMediaItemMapper::class) {
cameraRollMediaId.getAsClass()?.hookConstructor(HookStage.AFTER) { param ->
//set the durationMs field
param.thisObject<Any>()
.setObjectField(durationMsField.get()!!, -1L)
}
}
}
}
//TODO: allow split from any source
if (mode == "split") {
// memories grid
context.mappings.useMapper(DefaultMediaItemMapper::class) {
cameraRollMediaId.getAsClass()?.hookConstructor(HookStage.AFTER) { param ->
//set the durationMs field
param.thisObject<Any>()
.setObjectField(durationMsField.get()!!, -1L)
}
}
// chat camera roll grid
findClass("com.snap.composer.memories.MemoriesPickerVideoDurationConfig").hookConstructor(HookStage.AFTER) { param ->
param.thisObject<Any>().apply {
setObjectField("_maxSingleItemDurationMs", null)
setObjectField("_maxTotalDurationMs", null)
// chat camera roll grid
findClass("com.snap.composer.memories.MemoriesPickerVideoDurationConfig").hookConstructor(HookStage.AFTER) { param ->
param.thisObject<Any>().apply {
setObjectField("_maxSingleItemDurationMs", null)
setObjectField("_maxTotalDurationMs", null)
}
}
}
}

View File

@@ -2,17 +2,18 @@ package me.rhunk.snapenhance.core.features.impl.global
import android.content.Intent
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hook
class DisableCustomTabs: Feature("Disable Custom Tabs", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
override fun onActivityCreate() {
class DisableCustomTabs: Feature("Disable Custom Tabs") {
override fun init() {
if (!context.config.global.disableCustomTabs.get()) return
context.mainActivity!!.packageManager.javaClass.hook("resolveService", HookStage.BEFORE) { param ->
val intent = param.arg<Intent>(0)
if (intent.action == "android.support.customtabs.action.CustomTabsService") {
param.setResult(null)
onNextActivityCreate { activity ->
activity.packageManager.javaClass.hook("resolveService", HookStage.BEFORE) { param ->
val intent = param.arg<Intent>(0)
if (intent.action == "android.support.customtabs.action.CustomTabsService") {
param.setResult(null)
}
}
}
}

View File

@@ -1,24 +1,24 @@
package me.rhunk.snapenhance.core.features.impl.global
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hook
import me.rhunk.snapenhance.mapper.impl.MemoriesPresenterMapper
class DisableMemoriesSnapFeed : Feature("Disable Memories Snap Feed", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
override fun onActivityCreate() {
class DisableMemoriesSnapFeed : Feature("Disable Memories Snap Feed") {
override fun init() {
if (!context.config.global.disableMemoriesSnapFeed.get()) return
onNextActivityCreate {
context.mappings.useMapper(MemoriesPresenterMapper::class) {
classReference.get()?.apply {
val getNameMethod = getMethod("getName") ?: return@apply
context.mappings.useMapper(MemoriesPresenterMapper::class) {
classReference.get()?.apply {
val getNameMethod = getMethod("getName") ?: return@apply
hook(onNavigationEventMethod.get()!!, HookStage.BEFORE) { param ->
val instance = param.thisObject<Any>()
hook(onNavigationEventMethod.get()!!, HookStage.BEFORE) { param ->
val instance = param.thisObject<Any>()
if (getNameMethod.invoke(instance) == "MemoriesAsyncPresenterFragmentSubscriber") {
param.setResult(null)
if (getNameMethod.invoke(instance) == "MemoriesAsyncPresenterFragmentSubscriber") {
param.setResult(null)
}
}
}
}

View File

@@ -4,9 +4,8 @@ import me.rhunk.snapenhance.common.util.protobuf.ProtoReader
import me.rhunk.snapenhance.core.event.events.impl.NetworkApiRequestEvent
import me.rhunk.snapenhance.core.event.events.impl.UnaryCallEvent
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
class DisableMetrics : Feature("DisableMetrics", loadParams = FeatureLoadParams.INIT_SYNC) {
class DisableMetrics : Feature("DisableMetrics") {
override fun init() {
if (!context.config.global.disableMetrics.get()) return

View File

@@ -2,11 +2,10 @@ package me.rhunk.snapenhance.core.features.impl.global
import android.content.ContextWrapper
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hook
class DisableTelecomFramework: Feature("Disable Telecom Framework", loadParams = FeatureLoadParams.INIT_SYNC) {
class DisableTelecomFramework: Feature("Disable Telecom Framework") {
override fun init() {
if (!context.config.global.disableTelecomFramework.get()) return

View File

@@ -2,21 +2,22 @@ package me.rhunk.snapenhance.core.features.impl.global
import android.app.AlertDialog
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hook
import java.lang.reflect.Modifier
class GooglePlayServicesDialogs : Feature("Disable GMS Dialogs", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) {
override fun asyncOnActivityCreate() {
class GooglePlayServicesDialogs : Feature("Disable GMS Dialogs") {
override fun init() {
if (!context.config.global.disableGooglePlayDialogs.get()) return
findClass("com.google.android.gms.common.GoogleApiAvailability").methods
.first { Modifier.isStatic(it.modifiers) && it.returnType == AlertDialog::class.java }.let { method ->
method.hook(HookStage.BEFORE) { param ->
context.log.verbose("GoogleApiAvailability.showErrorDialogFragment() called, returning null")
param.setResult(null)
}
onNextActivityCreate(defer = true) {
findClass("com.google.android.gms.common.GoogleApiAvailability").methods
.first { Modifier.isStatic(it.modifiers) && it.returnType == AlertDialog::class.java }.let { method ->
method.hook(HookStage.BEFORE) { param ->
context.log.verbose("GoogleApiAvailability.showErrorDialogFragment() called, returning null")
param.setResult(null)
}
}
}
}
}

View File

@@ -2,13 +2,12 @@ package me.rhunk.snapenhance.core.features.impl.global
import android.graphics.Bitmap
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hook
import me.rhunk.snapenhance.mapper.impl.MediaQualityLevelProviderMapper
import java.lang.reflect.Method
class MediaUploadQualityOverride : Feature("Media Upload Quality Override", loadParams = FeatureLoadParams.INIT_SYNC) {
class MediaUploadQualityOverride : Feature("Media Upload Quality Override") {
override fun init() {
if (context.config.global.mediaUploadQualityConfig.forceVideoUploadSourceQuality.get()) {
context.mappings.useMapper(MediaQualityLevelProviderMapper::class) {

View File

@@ -1,7 +1,6 @@
package me.rhunk.snapenhance.core.features.impl.global
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hook
import me.rhunk.snapenhance.core.util.hook.hookConstructor
@@ -9,7 +8,7 @@ import me.rhunk.snapenhance.core.util.ktx.getObjectField
import me.rhunk.snapenhance.core.util.ktx.setObjectField
import me.rhunk.snapenhance.mapper.impl.PlusSubscriptionMapper
class SnapchatPlus: Feature("SnapchatPlus", loadParams = FeatureLoadParams.INIT_SYNC) {
class SnapchatPlus: Feature("SnapchatPlus") {
private val originalSubscriptionTime = (System.currentTimeMillis() - 7776000000L)
private val expirationTimeMillis = (System.currentTimeMillis() + 15552000000L)

View File

@@ -11,7 +11,6 @@ import me.rhunk.snapenhance.common.data.ContentType
import me.rhunk.snapenhance.common.data.MessageUpdate
import me.rhunk.snapenhance.core.event.events.impl.SendMessageWithContentEvent
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.features.impl.spying.StealthMode
import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper
import me.rhunk.snapenhance.core.util.ktx.getObjectFieldOrNull
@@ -19,7 +18,7 @@ import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlin.random.Random
class AutoMarkAsRead : Feature("Auto Mark As Read", loadParams = FeatureLoadParams.INIT_SYNC) {
class AutoMarkAsRead : Feature("Auto Mark As Read") {
val canMarkConversationAsRead by lazy { context.config.messaging.autoMarkAsRead.get().contains("conversation_read") }
fun markConversationsAsRead(conversationIds: List<String>) {

View File

@@ -4,7 +4,6 @@ import me.rhunk.snapenhance.common.data.MessageState
import me.rhunk.snapenhance.common.data.MessageUpdate
import me.rhunk.snapenhance.common.data.MessagingRuleType
import me.rhunk.snapenhance.core.event.events.impl.ConversationUpdateEvent
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.features.MessagingRuleFeature
import me.rhunk.snapenhance.core.features.impl.spying.MessageLogger
import me.rhunk.snapenhance.core.features.impl.spying.StealthMode
@@ -16,7 +15,7 @@ import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID
import me.rhunk.snapenhance.mapper.impl.CallbackMapper
import java.util.concurrent.Executors
class AutoSave : MessagingRuleFeature("Auto Save", MessagingRuleType.AUTO_SAVE, loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) {
class AutoSave : MessagingRuleFeature("Auto Save", MessagingRuleType.AUTO_SAVE) {
private val asyncSaveExecutorService = Executors.newSingleThreadExecutor()
private val messageLogger by lazy { context.feature(MessageLogger::class) }
@@ -68,37 +67,39 @@ class AutoSave : MessagingRuleFeature("Auto Save", MessagingRuleType.AUTO_SAVE,
return canUseRule(targetConversationId)
}
override fun asyncOnActivityCreate() {
// called when enter in a conversation
context.mappings.useMapper(CallbackMapper::class) {
callbacks.getClass("FetchConversationWithMessagesCallback")?.hook(
"onFetchConversationWithMessagesComplete",
HookStage.BEFORE,
{ autoSaveFilter.isNotEmpty() }
) { param ->
val conversationId = SnapUUID(param.arg<Any>(0).getObjectField("mConversationId")!!)
if (!canSaveInConversation(conversationId.toString())) return@hook
override fun init() {
onNextActivityCreate(defer = true) {
// called when enter in a conversation
context.mappings.useMapper(CallbackMapper::class) {
callbacks.getClass("FetchConversationWithMessagesCallback")?.hook(
"onFetchConversationWithMessagesComplete",
HookStage.BEFORE,
{ autoSaveFilter.isNotEmpty() }
) { param ->
val conversationId = SnapUUID(param.arg<Any>(0).getObjectField("mConversationId")!!)
if (!canSaveInConversation(conversationId.toString())) return@hook
val messages = param.arg<List<Any>>(1).map { Message(it) }
messages.forEach {
if (!canSaveMessage(it)) return@forEach
asyncSaveExecutorService.submit {
saveMessage(conversationId.toString(), it)
val messages = param.arg<List<Any>>(1).map { Message(it) }
messages.forEach {
if (!canSaveMessage(it)) return@forEach
asyncSaveExecutorService.submit {
saveMessage(conversationId.toString(), it)
}
}
}
}
}
context.event.subscribe(
ConversationUpdateEvent::class,
{ autoSaveFilter.isNotEmpty() }
) { event ->
if (!canSaveInConversation(event.conversationId)) return@subscribe
context.event.subscribe(
ConversationUpdateEvent::class,
{ autoSaveFilter.isNotEmpty() }
) { event ->
if (!canSaveInConversation(event.conversationId)) return@subscribe
event.messages.forEach { message ->
if (!canSaveMessage(message)) return@forEach
asyncSaveExecutorService.submit {
saveMessage(event.conversationId, message)
event.messages.forEach { message ->
if (!canSaveMessage(message)) return@forEach
asyncSaveExecutorService.submit {
saveMessage(event.conversationId, message)
}
}
}
}

View File

@@ -2,15 +2,16 @@ package me.rhunk.snapenhance.core.features.impl.messaging
import me.rhunk.snapenhance.core.event.events.impl.BuildMessageEvent
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
class BypassMessageActionRestrictions : Feature("Bypass Message Action Restrictions", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
override fun onActivityCreate() {
class BypassMessageActionRestrictions : Feature("Bypass Message Action Restrictions") {
override fun init() {
if (!context.config.messaging.bypassMessageActionRestrictions.get()) return
context.event.subscribe(BuildMessageEvent::class, priority = 102) { event ->
event.message.messageMetadata?.apply {
isSaveable = true
isReactable = true
onNextActivityCreate {
context.event.subscribe(BuildMessageEvent::class, priority = 102) { event ->
event.message.messageMetadata?.apply {
isSaveable = true
isReactable = true
}
}
}
}

View File

@@ -5,7 +5,6 @@ import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper
import me.rhunk.snapenhance.core.ui.children
import me.rhunk.snapenhance.core.util.hook.HookAdapter
@@ -13,7 +12,7 @@ import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hook
import me.rhunk.snapenhance.core.util.ktx.getId
class CallStartConfirmation : Feature("CallStartConfirmation", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
class CallStartConfirmation : Feature("CallStartConfirmation") {
private fun hookTouchEvent(param: HookAdapter, motionEvent: MotionEvent, onConfirm: () -> Unit) {
if (motionEvent.action != MotionEvent.ACTION_UP) return
param.setResult(true)
@@ -26,37 +25,39 @@ class CallStartConfirmation : Feature("CallStartConfirmation", loadParams = Feat
}
@SuppressLint("DiscouragedApi")
override fun onActivityCreate() {
override fun init() {
if (!context.config.messaging.callStartConfirmation.get()) return
val callButtonsStub = context.resources.getId("call_buttons_stub")
onNextActivityCreate {
val callButtonsStub = context.resources.getId("call_buttons_stub")
findClass("com.snap.composer.views.ComposerRootView").hook("dispatchTouchEvent", HookStage.BEFORE) { param ->
val view = param.thisObject() as? ViewGroup ?: return@hook
if (view.id != callButtonsStub) return@hook
val childComposerView = view.getChildAt(0) as? ViewGroup ?: return@hook
// check if the child composer view contains 2 call buttons
if (childComposerView.children().count {
it::class.java == childComposerView::class.java
} != 2) return@hook
hookTouchEvent(param, param.arg(0)) {
param.invokeOriginal()
findClass("com.snap.composer.views.ComposerRootView").hook("dispatchTouchEvent", HookStage.BEFORE) { param ->
val view = param.thisObject() as? ViewGroup ?: return@hook
if (view.id != callButtonsStub) return@hook
val childComposerView = view.getChildAt(0) as? ViewGroup ?: return@hook
// check if the child composer view contains 2 call buttons
if (childComposerView.children().count {
it::class.java == childComposerView::class.java
} != 2) return@hook
hookTouchEvent(param, param.arg(0)) {
param.invokeOriginal()
}
}
}
val callButton1 = context.resources.getId("friend_action_button3")
val callButton2 = context.resources.getId("friend_action_button4")
val callButton1 = context.resources.getId("friend_action_button3")
val callButton2 = context.resources.getId("friend_action_button4")
findClass("com.snap.ui.view.stackdraw.StackDrawLayout").hook("onTouchEvent", HookStage.BEFORE) { param ->
val view = param.thisObject<View>()
if (view.id != callButton1 && view.id != callButton2) return@hook
findClass("com.snap.ui.view.stackdraw.StackDrawLayout").hook("onTouchEvent", HookStage.BEFORE) { param ->
val view = param.thisObject<View>()
if (view.id != callButton1 && view.id != callButton2) return@hook
hookTouchEvent(param, param.arg(0)) {
arrayOf(
MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 0f, 0f, 0),
MotionEvent.obtain(0, 0, MotionEvent.ACTION_UP, 0f, 0f, 0)
).forEach {
param.invokeOriginal(arrayOf(it))
hookTouchEvent(param, param.arg(0)) {
arrayOf(
MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 0f, 0f, 0),
MotionEvent.obtain(0, 0, MotionEvent.ACTION_UP, 0f, 0f, 0)
).forEach {
param.invokeOriginal(arrayOf(it))
}
}
}
}

View File

@@ -1,22 +1,23 @@
package me.rhunk.snapenhance.core.features.impl.messaging
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hookConstructor
import me.rhunk.snapenhance.core.util.ktx.getObjectField
import me.rhunk.snapenhance.core.util.ktx.setEnumField
class DisableReplayInFF : Feature("DisableReplayInFF", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) {
override fun asyncOnActivityCreate() {
class DisableReplayInFF : Feature("DisableReplayInFF") {
override fun init() {
val state by context.config.messaging.disableReplayInFF
findClass("com.snapchat.client.messaging.InteractionInfo")
.hookConstructor(HookStage.AFTER, { state }) { param ->
val instance = param.thisObject<Any>()
if (instance.getObjectField("mLongPressActionState").toString() == "REQUEST_SNAP_REPLAY") {
instance.setEnumField("mLongPressActionState", "SHOW_CONVERSATION_ACTION_MENU")
}
onNextActivityCreate(defer = true) {
findClass("com.snapchat.client.messaging.InteractionInfo")
.hookConstructor(HookStage.AFTER, { state }) { param ->
val instance = param.thisObject<Any>()
if (instance.getObjectField("mLongPressActionState").toString() == "REQUEST_SNAP_REPLAY") {
instance.setEnumField("mLongPressActionState", "SHOW_CONVERSATION_ACTION_MENU")
}
}
}
}
}

View File

@@ -7,7 +7,6 @@ import me.rhunk.snapenhance.common.ReceiversConfig
import me.rhunk.snapenhance.core.event.events.impl.ConversationUpdateEvent
import me.rhunk.snapenhance.core.event.events.impl.OnSnapInteractionEvent
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.features.impl.spying.StealthMode
import me.rhunk.snapenhance.core.util.EvictingMap
import me.rhunk.snapenhance.core.util.hook.HookStage
@@ -25,7 +24,7 @@ import me.rhunk.snapenhance.mapper.impl.FriendsFeedEventDispatcherMapper
import java.util.UUID
import java.util.concurrent.Future
class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC or FeatureLoadParams.INIT_ASYNC or FeatureLoadParams.INIT_SYNC) {
class Messaging : Feature("Messaging") {
var conversationManager: ConversationManager? = null
private set
private var conversationManagerDelegate: Any? = null
@@ -48,6 +47,7 @@ class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_C
}
override fun init() {
val stealthMode = context.feature(StealthMode::class)
context.classCache.conversationManager.hookConstructor(HookStage.BEFORE) { param ->
conversationManager = ConversationManager(context, param.thisObject())
context.messagingBridge.triggerSessionStart()
@@ -83,6 +83,80 @@ class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_C
}
}
}
defer {
arrayOf("activate", "deactivate", "processTypingActivity").forEach { hook ->
context.classCache.presenceSession.hook(hook, HookStage.BEFORE, {
context.config.messaging.hideBitmojiPresence.get() || stealthMode.canUseRule(openedConversationUUID.toString())
}) {
it.setResult(null)
}
}
context.classCache.presenceSession.hook("startPeeking", HookStage.BEFORE, {
context.config.messaging.hidePeekAPeek.get() || stealthMode.canUseRule(openedConversationUUID.toString())
}) { it.setResult(null) }
//get last opened snap for media downloader
context.event.subscribe(OnSnapInteractionEvent::class) { event ->
openedConversationUUID = event.conversationId
lastFocusedMessageId = event.messageId
}
context.classCache.conversationManager.hook("fetchMessage", HookStage.BEFORE) { param ->
val conversationId = SnapUUID(param.arg(0)).toString()
if (openedConversationUUID?.toString() == conversationId) {
lastFocusedMessageId = param.arg(1)
}
}
context.classCache.conversationManager.hook("sendTypingNotification", HookStage.BEFORE, {
context.config.messaging.hideTypingNotifications.get() || stealthMode.canUseRule(openedConversationUUID.toString())
}) {
it.setResult(null)
}
}
onNextActivityCreate {
context.mappings.useMapper(FriendsFeedEventDispatcherMapper::class) {
classReference.getAsClass()?.hook("onItemLongPress", HookStage.BEFORE) { param ->
val viewItemContainer = param.arg<Any>(0)
val viewItem = viewItemContainer.getObjectField(viewModelField.get()!!).toString()
val conversationId = viewItem.substringAfter("conversationId: ").substring(0, 36).also {
if (it.startsWith("null")) return@hook
}
lastFocusedConversationId = conversationId
lastFocusedConversationType = context.database.getConversationType(conversationId) ?: 0
}
}
context.classCache.feedEntry.hookConstructor(HookStage.AFTER) { param ->
val instance = param.thisObject<Any>()
val interactionInfo = instance.getObjectFieldOrNull("mInteractionInfo") ?: return@hookConstructor
val messages = (interactionInfo.getObjectFieldOrNull("mMessages") as? List<*>)?.map { Message(it) } ?: return@hookConstructor
val conversationId = SnapUUID(instance.getObjectFieldOrNull("mConversationId") ?: return@hookConstructor).toString()
val myUserId = context.database.myUserId
feedCachedSnapMessages[conversationId] = messages.filter { msg ->
msg.messageMetadata?.openedBy?.none { it.toString() == myUserId } == true
}.sortedBy { it.orderKey }.mapNotNull { it.messageDescriptor?.messageId }
}
context.classCache.conversationManager.apply {
hook("enterConversation", HookStage.BEFORE) { param ->
openedConversationUUID = SnapUUID(param.arg(0))
if (context.config.messaging.bypassMessageRetentionPolicy.get()) {
val callback = param.argNullable<Any>(2) ?: return@hook
callback::class.java.methods.firstOrNull { it.name == "onSuccess" }?.invoke(callback)
param.setResult(null)
}
}
hook("exitConversation", HookStage.BEFORE) {
openedConversationUUID = null
}
}
}
}
fun getFeedCachedMessageIds(conversationId: String) = feedCachedSnapMessages[conversationId]
@@ -116,82 +190,6 @@ class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_C
}
}
override fun onActivityCreate() {
context.mappings.useMapper(FriendsFeedEventDispatcherMapper::class) {
classReference.getAsClass()?.hook("onItemLongPress", HookStage.BEFORE) { param ->
val viewItemContainer = param.arg<Any>(0)
val viewItem = viewItemContainer.getObjectField(viewModelField.get()!!).toString()
val conversationId = viewItem.substringAfter("conversationId: ").substring(0, 36).also {
if (it.startsWith("null")) return@hook
}
lastFocusedConversationId = conversationId
lastFocusedConversationType = context.database.getConversationType(conversationId) ?: 0
}
}
context.classCache.feedEntry.hookConstructor(HookStage.AFTER) { param ->
val instance = param.thisObject<Any>()
val interactionInfo = instance.getObjectFieldOrNull("mInteractionInfo") ?: return@hookConstructor
val messages = (interactionInfo.getObjectFieldOrNull("mMessages") as? List<*>)?.map { Message(it) } ?: return@hookConstructor
val conversationId = SnapUUID(instance.getObjectFieldOrNull("mConversationId") ?: return@hookConstructor).toString()
val myUserId = context.database.myUserId
feedCachedSnapMessages[conversationId] = messages.filter { msg ->
msg.messageMetadata?.openedBy?.none { it.toString() == myUserId } == true
}.sortedBy { it.orderKey }.mapNotNull { it.messageDescriptor?.messageId }
}
context.classCache.conversationManager.apply {
hook("enterConversation", HookStage.BEFORE) { param ->
openedConversationUUID = SnapUUID(param.arg(0))
if (context.config.messaging.bypassMessageRetentionPolicy.get()) {
val callback = param.argNullable<Any>(2) ?: return@hook
callback::class.java.methods.firstOrNull { it.name == "onSuccess" }?.invoke(callback)
param.setResult(null)
}
}
hook("exitConversation", HookStage.BEFORE) {
openedConversationUUID = null
}
}
}
override fun asyncInit() {
val stealthMode = context.feature(StealthMode::class)
arrayOf("activate", "deactivate", "processTypingActivity").forEach { hook ->
context.classCache.presenceSession.hook(hook, HookStage.BEFORE, {
context.config.messaging.hideBitmojiPresence.get() || stealthMode.canUseRule(openedConversationUUID.toString())
}) {
it.setResult(null)
}
}
context.classCache.presenceSession.hook("startPeeking", HookStage.BEFORE, {
context.config.messaging.hidePeekAPeek.get() || stealthMode.canUseRule(openedConversationUUID.toString())
}) { it.setResult(null) }
//get last opened snap for media downloader
context.event.subscribe(OnSnapInteractionEvent::class) { event ->
openedConversationUUID = event.conversationId
lastFocusedMessageId = event.messageId
}
context.classCache.conversationManager.hook("fetchMessage", HookStage.BEFORE) { param ->
val conversationId = SnapUUID(param.arg(0)).toString()
if (openedConversationUUID?.toString() == conversationId) {
lastFocusedMessageId = param.arg(1)
}
}
context.classCache.conversationManager.hook("sendTypingNotification", HookStage.BEFORE, {
context.config.messaging.hideTypingNotifications.get() || stealthMode.canUseRule(openedConversationUUID.toString())
}) {
it.setResult(null)
}
}
fun fetchSnapchatterInfos(userIds: List<String>): List<Snapchatter> {
val identity = identityDelegate ?: return emptyList()
val snapUUIDs = userIds.map {

View File

@@ -23,7 +23,6 @@ import me.rhunk.snapenhance.common.util.snap.MediaDownloaderHelper
import me.rhunk.snapenhance.common.util.snap.SnapWidgetBroadcastReceiverHelper
import me.rhunk.snapenhance.core.event.events.impl.SnapWidgetBroadcastReceiveEvent
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.features.impl.FriendMutationObserver
import me.rhunk.snapenhance.core.features.impl.downloader.MediaDownloader
import me.rhunk.snapenhance.core.features.impl.downloader.decoder.AttachmentType
@@ -39,7 +38,7 @@ import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID
import okhttp3.RequestBody.Companion.toRequestBody
import kotlin.coroutines.suspendCoroutine
class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.INIT_SYNC) {
class Notifications : Feature("Notifications") {
inner class NotificationData(
val tag: String?,
val id: Int,
@@ -301,7 +300,7 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN
)
}
}.onFailure {
context.log.warn("Failed to set notification group key: ${it.stackTraceToString()}", featureKey)
context.log.warn("Failed to set notification group key: ${it.stackTraceToString()}", key)
}
}

View File

@@ -5,12 +5,11 @@ import me.rhunk.snapenhance.common.util.protobuf.ProtoEditor
import me.rhunk.snapenhance.core.event.events.impl.NativeUnaryCallEvent
import me.rhunk.snapenhance.core.event.events.impl.SendMessageWithContentEvent
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hook
class PreventMessageSending : Feature("Prevent message sending", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) {
override fun asyncOnActivityCreate() {
class PreventMessageSending : Feature("Prevent message sending") {
override fun init() {
val preventMessageSending by context.config.messaging.preventMessageSending
context.event.subscribe(NativeUnaryCallEvent::class, { preventMessageSending.contains("snap_replay") }) { event ->

View File

@@ -8,14 +8,13 @@ import me.rhunk.snapenhance.common.util.protobuf.ProtoReader
import me.rhunk.snapenhance.core.event.events.impl.NativeUnaryCallEvent
import me.rhunk.snapenhance.core.event.events.impl.SendMessageWithContentEvent
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.features.impl.experiments.MediaFilePicker
import me.rhunk.snapenhance.core.messaging.MessageSender
import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper
import me.rhunk.snapenhance.nativelib.NativeLib
import java.util.Locale
class SendOverride : Feature("Send Override", loadParams = FeatureLoadParams.INIT_SYNC) {
class SendOverride : Feature("Send Override") {
private var isLastSnapSavable = false
private val typeNames by lazy {
mutableListOf("ORIGINAL", "SNAP", "NOTE").also {

View File

@@ -6,27 +6,27 @@ import me.rhunk.snapenhance.common.util.protobuf.ProtoEditor
import me.rhunk.snapenhance.common.util.protobuf.ProtoReader
import me.rhunk.snapenhance.core.event.events.impl.BuildMessageEvent
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
class UnlimitedSnapViewTime :
Feature("UnlimitedSnapViewTime", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
override fun onActivityCreate() {
val state by context.config.messaging.unlimitedSnapViewTime
class UnlimitedSnapViewTime : Feature("UnlimitedSnapViewTime") {
override fun init() {
onNextActivityCreate {
val state by context.config.messaging.unlimitedSnapViewTime
context.event.subscribe(BuildMessageEvent::class, { state }, priority = 101) { event ->
if (event.message.messageState != MessageState.COMMITTED) return@subscribe
if (event.message.messageContent!!.contentType != ContentType.SNAP) return@subscribe
context.event.subscribe(BuildMessageEvent::class, { state }, priority = 101) { event ->
if (event.message.messageState != MessageState.COMMITTED) return@subscribe
if (event.message.messageContent!!.contentType != ContentType.SNAP) return@subscribe
val messageContent = event.message.messageContent
val messageContent = event.message.messageContent
val mediaAttributes = ProtoReader(messageContent!!.content!!).followPath(11, 5, 2) ?: return@subscribe
if (mediaAttributes.contains(6)) return@subscribe
messageContent.content = ProtoEditor(messageContent.content!!).apply {
edit(11, 5, 2) {
remove(8)
addBuffer(6, byteArrayOf())
}
}.toByteArray()
val mediaAttributes = ProtoReader(messageContent!!.content!!).followPath(11, 5, 2) ?: return@subscribe
if (mediaAttributes.contains(6)) return@subscribe
messageContent.content = ProtoEditor(messageContent.content!!).apply {
edit(11, 5, 2) {
remove(8)
addBuffer(6, byteArrayOf())
}
}.toByteArray()
}
}
}
}

View File

@@ -12,7 +12,6 @@ import me.rhunk.snapenhance.common.util.lazyBridge
import me.rhunk.snapenhance.common.util.protobuf.ProtoReader
import me.rhunk.snapenhance.common.util.toParcelable
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.features.impl.messaging.Messaging
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hook
@@ -23,7 +22,7 @@ import me.rhunk.snapenhance.nativelib.NativeLib
import java.lang.reflect.Method
import java.nio.ByteBuffer
class FriendTracker : Feature("Friend Tracker", loadParams = FeatureLoadParams.INIT_SYNC) {
class FriendTracker : Feature("Friend Tracker") {
private val conversationPresenceState = mutableMapOf<String, MutableMap<String, FriendPresenceState?>>() // conversationId -> (userId -> state)
private val tracker by lazyBridge { context.bridgeClient.getTracker() }
private val notificationManager by lazy { context.androidContext.getSystemService(NotificationManager::class.java).apply {

View File

@@ -6,7 +6,6 @@ import android.app.NotificationManager
import android.app.PendingIntent
import me.rhunk.snapenhance.common.Constants
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hook
import me.rhunk.snapenhance.core.util.hook.hookConstructor
@@ -16,7 +15,7 @@ import me.rhunk.snapenhance.mapper.impl.CallbackMapper
import java.util.concurrent.ConcurrentHashMap
import kotlin.time.Duration.Companion.milliseconds
class HalfSwipeNotifier : Feature("Half Swipe Notifier", loadParams = FeatureLoadParams.INIT_SYNC) {
class HalfSwipeNotifier : Feature("Half Swipe Notifier") {
private val peekingConversations = ConcurrentHashMap<String, List<String>>()
private val startPeekingTimestamps = ConcurrentHashMap<String, Long>()

View File

@@ -18,18 +18,13 @@ import me.rhunk.snapenhance.common.util.protobuf.ProtoReader
import me.rhunk.snapenhance.core.event.events.impl.BindViewEvent
import me.rhunk.snapenhance.core.event.events.impl.BuildMessageEvent
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.ui.addForegroundDrawable
import me.rhunk.snapenhance.core.ui.removeForegroundDrawable
import me.rhunk.snapenhance.core.util.EvictingMap
import java.util.concurrent.Executors
import kotlin.system.measureTimeMillis
class MessageLogger : Feature("MessageLogger",
loadParams = FeatureLoadParams.INIT_SYNC or
FeatureLoadParams.ACTIVITY_CREATE_SYNC or
FeatureLoadParams.ACTIVITY_CREATE_ASYNC
) {
class MessageLogger : Feature("MessageLogger") {
companion object {
const val PREFETCH_MESSAGE_COUNT = 20
const val PREFETCH_FEED_COUNT = 20
@@ -96,23 +91,20 @@ class MessageLogger : Feature("MessageLogger",
return computeMessageIdentifier(conversationId, serverMessageId)
}
override fun asyncOnActivityCreate() {
if (!isEnabled || !context.database.hasArroyo()) {
return
}
measureTimeMillis {
val conversationIds = context.database.getFeedEntries(PREFETCH_FEED_COUNT).map { it.key!! }
if (conversationIds.isEmpty()) return@measureTimeMillis
fetchedMessages.addAll(loggerInterface.getLoggedIds(conversationIds.toTypedArray(), PREFETCH_MESSAGE_COUNT).toList())
}.also { context.log.verbose("Loaded ${fetchedMessages.size} cached messages in ${it}ms") }
}
override fun init() {
if (!isEnabled) return
val keepMyOwnMessages = context.config.messaging.messageLogger.keepMyOwnMessages.get()
val messageFilter by context.config.messaging.messageLogger.messageFilter
onNextActivityCreate(defer = true) {
if (!context.database.hasArroyo()) return@onNextActivityCreate
measureTimeMillis {
val conversationIds = context.database.getFeedEntries(PREFETCH_FEED_COUNT).map { it.key!! }
if (conversationIds.isEmpty()) return@measureTimeMillis
fetchedMessages.addAll(loggerInterface.getLoggedIds(conversationIds.toTypedArray(), PREFETCH_MESSAGE_COUNT).toList())
}.also { context.log.verbose("Loaded ${fetchedMessages.size} cached messages in ${it}ms") }
}
context.event.subscribe(BuildMessageEvent::class, priority = 1) { event ->
val messageInstance = event.message.instanceNonNull()
if (event.message.messageState != MessageState.COMMITTED) return@subscribe
@@ -183,10 +175,6 @@ class MessageLogger : Feature("MessageLogger",
deletedMessageCache[uniqueMessageIdentifier] = deletedMessageObject
}
}
override fun onActivityCreate() {
if (!isEnabled) return
context.event.subscribe(BindViewEvent::class) { event ->
event.chatMessage { conversationId, messageId ->

View File

@@ -2,14 +2,13 @@ package me.rhunk.snapenhance.core.features.impl.spying
import me.rhunk.snapenhance.common.data.MessagingRuleType
import me.rhunk.snapenhance.core.event.events.impl.OnSnapInteractionEvent
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.features.MessagingRuleFeature
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hook
import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID
import java.util.concurrent.CopyOnWriteArraySet
class StealthMode : MessagingRuleFeature("StealthMode", MessagingRuleType.STEALTH, loadParams = FeatureLoadParams.INIT_SYNC) {
class StealthMode : MessagingRuleFeature("StealthMode", MessagingRuleType.STEALTH) {
private val displayedMessageQueue = CopyOnWriteArraySet<Long>()
fun addDisplayedMessageException(clientMessageId: Long) {

View File

@@ -5,12 +5,11 @@ import android.content.ContentResolver
import android.database.ContentObserver
import android.net.Uri
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hook
class BypassScreenshotDetection : Feature("BypassScreenshotDetection", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
override fun onActivityCreate() {
class BypassScreenshotDetection : Feature("BypassScreenshotDetection") {
override fun init() {
if (!context.config.messaging.bypassScreenshotDetection.get()) return
Activity::class.java.hook("registerScreenCaptureCallback", HookStage.BEFORE) { param ->
param.setResult(null)

View File

@@ -12,100 +12,101 @@ import android.media.Image
import android.media.ImageReader
import android.util.Range
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hook
import me.rhunk.snapenhance.core.util.ktx.setObjectField
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
class CameraTweaks : Feature("Camera Tweaks", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
class CameraTweaks : Feature("Camera Tweaks") {
private fun parseResolution(resolution: String): IntArray? {
return runCatching { resolution.split("x").map { it.toInt() }.toIntArray() }.getOrNull()
}
@SuppressLint("MissingPermission", "DiscouragedApi")
override fun onActivityCreate() {
val config = context.config.camera
override fun init() {
onNextActivityCreate {
val config = context.config.camera
val frontCameraId = runCatching { context.androidContext.getSystemService(CameraManager::class.java).run {
cameraIdList.firstOrNull { getCameraCharacteristics(it).get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_FRONT }
} }.getOrNull()
val frontCameraId = runCatching { context.androidContext.getSystemService(CameraManager::class.java).run {
cameraIdList.firstOrNull { getCameraCharacteristics(it).get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_FRONT }
} }.getOrNull()
if (config.disableCameras.get().isNotEmpty() && frontCameraId != null) {
ContextWrapper::class.java.hook("checkPermission", HookStage.BEFORE) { param ->
val permission = param.arg<String>(0)
if (permission == Manifest.permission.CAMERA) {
param.setResult(PackageManager.PERMISSION_GRANTED)
}
}
}
var isLastCameraFront = false
CameraManager::class.java.hook("openCamera", HookStage.BEFORE) { param ->
val cameraManager = param.thisObject() as? CameraManager ?: return@hook
val cameraId = param.arg<String>(0)
val disabledCameras = config.disableCameras.get()
if (disabledCameras.size >= 2) {
param.setResult(null)
return@hook
}
isLastCameraFront = cameraId == frontCameraId
if (disabledCameras.size != 1) return@hook
// trick to replace unwanted camera with another one
if ((disabledCameras.contains("front") && isLastCameraFront) || (disabledCameras.contains("back") && !isLastCameraFront)) {
param.setArg(0, cameraManager.cameraIdList.filterNot { it == cameraId }.firstOrNull() ?: return@hook)
isLastCameraFront = !isLastCameraFront
}
}
ImageReader::class.java.hook("newInstance", HookStage.BEFORE) { param ->
val captureResolutionConfig = config.customResolution.getNullable()?.takeIf { it.isNotEmpty() }?.let { parseResolution(it) }
?: (if (isLastCameraFront) config.overrideFrontResolution.getNullable() else config.overrideBackResolution.getNullable())?.let { parseResolution(it) } ?: return@hook
param.setArg(0, captureResolutionConfig[0])
param.setArg(1, captureResolutionConfig[1])
}
CameraCharacteristics::class.java.hook("get", HookStage.AFTER) { param ->
val key = param.argNullable<Key<*>>(0) ?: return@hook
if (key == CameraCharacteristics.LENS_FACING) {
val disabledCameras = config.disableCameras.get()
//FIXME: unexpected behavior when app is resumed
if (disabledCameras.size == 1) {
val isFrontCamera = param.getResult() as? Int == CameraCharacteristics.LENS_FACING_FRONT
if ((disabledCameras.contains("front") && isFrontCamera) || (disabledCameras.contains("back") && !isFrontCamera)) {
param.setResult(if (isFrontCamera) CameraCharacteristics.LENS_FACING_BACK else CameraCharacteristics.LENS_FACING_FRONT)
if (config.disableCameras.get().isNotEmpty() && frontCameraId != null) {
ContextWrapper::class.java.hook("checkPermission", HookStage.BEFORE) { param ->
val permission = param.arg<String>(0)
if (permission == Manifest.permission.CAMERA) {
param.setResult(PackageManager.PERMISSION_GRANTED)
}
}
}
if (key == CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES) {
val isFrontCamera = param.invokeOriginal(
arrayOf(CameraCharacteristics.LENS_FACING)
) == CameraCharacteristics.LENS_FACING_FRONT
val customFrameRate = (if (isFrontCamera) config.frontCustomFrameRate.getNullable() else config.backCustomFrameRate.getNullable())?.toIntOrNull() ?: return@hook
param.setResult(arrayOf(Range(customFrameRate, customFrameRate)))
}
}
var isLastCameraFront = false
if (config.blackPhotos.get()) {
findClass("android.media.ImageReader\$SurfaceImage").hook("getPlanes", HookStage.AFTER) { param ->
val image = param.thisObject() as? Image ?: return@hook
val planes = param.getResult() as? Array<*> ?: return@hook
val output = ByteArrayOutputStream()
Bitmap.createBitmap(image.width, image.height, Bitmap.Config.ARGB_8888).apply {
compress(Bitmap.CompressFormat.JPEG, 100, output)
recycle()
CameraManager::class.java.hook("openCamera", HookStage.BEFORE) { param ->
val cameraManager = param.thisObject() as? CameraManager ?: return@hook
val cameraId = param.arg<String>(0)
val disabledCameras = config.disableCameras.get()
if (disabledCameras.size >= 2) {
param.setResult(null)
return@hook
}
planes.filterNotNull().forEach { plane ->
plane.setObjectField("mBuffer", ByteBuffer.wrap(output.toByteArray()))
isLastCameraFront = cameraId == frontCameraId
if (disabledCameras.size != 1) return@hook
// trick to replace unwanted camera with another one
if ((disabledCameras.contains("front") && isLastCameraFront) || (disabledCameras.contains("back") && !isLastCameraFront)) {
param.setArg(0, cameraManager.cameraIdList.filterNot { it == cameraId }.firstOrNull() ?: return@hook)
isLastCameraFront = !isLastCameraFront
}
}
ImageReader::class.java.hook("newInstance", HookStage.BEFORE) { param ->
val captureResolutionConfig = config.customResolution.getNullable()?.takeIf { it.isNotEmpty() }?.let { parseResolution(it) }
?: (if (isLastCameraFront) config.overrideFrontResolution.getNullable() else config.overrideBackResolution.getNullable())?.let { parseResolution(it) } ?: return@hook
param.setArg(0, captureResolutionConfig[0])
param.setArg(1, captureResolutionConfig[1])
}
CameraCharacteristics::class.java.hook("get", HookStage.AFTER) { param ->
val key = param.argNullable<Key<*>>(0) ?: return@hook
if (key == CameraCharacteristics.LENS_FACING) {
val disabledCameras = config.disableCameras.get()
//FIXME: unexpected behavior when app is resumed
if (disabledCameras.size == 1) {
val isFrontCamera = param.getResult() as? Int == CameraCharacteristics.LENS_FACING_FRONT
if ((disabledCameras.contains("front") && isFrontCamera) || (disabledCameras.contains("back") && !isFrontCamera)) {
param.setResult(if (isFrontCamera) CameraCharacteristics.LENS_FACING_BACK else CameraCharacteristics.LENS_FACING_FRONT)
}
}
}
if (key == CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES) {
val isFrontCamera = param.invokeOriginal(
arrayOf(CameraCharacteristics.LENS_FACING)
) == CameraCharacteristics.LENS_FACING_FRONT
val customFrameRate = (if (isFrontCamera) config.frontCustomFrameRate.getNullable() else config.backCustomFrameRate.getNullable())?.toIntOrNull() ?: return@hook
param.setResult(arrayOf(Range(customFrameRate, customFrameRate)))
}
}
if (config.blackPhotos.get()) {
findClass("android.media.ImageReader\$SurfaceImage").hook("getPlanes", HookStage.AFTER) { param ->
val image = param.thisObject() as? Image ?: return@hook
val planes = param.getResult() as? Array<*> ?: return@hook
val output = ByteArrayOutputStream()
Bitmap.createBitmap(image.width, image.height, Bitmap.Config.ARGB_8888).apply {
compress(Bitmap.CompressFormat.JPEG, 100, output)
recycle()
}
planes.filterNotNull().forEach { plane ->
plane.setObjectField("mBuffer", ByteBuffer.wrap(output.toByteArray()))
}
}
}
}

View File

@@ -4,11 +4,10 @@ import android.content.ContextWrapper
import android.content.pm.PackageManager
import me.rhunk.snapenhance.common.config.impl.Global
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hook
class DisablePermissionRequests : Feature("Disable Permission Requests", loadParams = FeatureLoadParams.INIT_SYNC) {
class DisablePermissionRequests : Feature("Disable Permission Requests") {
override fun init() {
val deniedPermissions by context.config.global.disablePermissionRequests
if (deniedPermissions.isEmpty()) return

View File

@@ -2,15 +2,16 @@ package me.rhunk.snapenhance.core.features.impl.tweaks
import android.media.AudioManager
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hook
class HideActiveMusic: Feature("Hide Active Music", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
override fun onActivityCreate() {
class HideActiveMusic: Feature("Hide Active Music") {
override fun init() {
if (!context.config.global.hideActiveMusic.get()) return
AudioManager::class.java.hook("isMusicActive", HookStage.BEFORE) {
it.setResult(false)
onNextActivityCreate {
AudioManager::class.java.hook("isMusicActive", HookStage.BEFORE) {
it.setResult(false)
}
}
}
}

View File

@@ -4,73 +4,74 @@ import android.view.View
import me.rhunk.snapenhance.core.event.events.impl.BindViewEvent
import me.rhunk.snapenhance.core.event.events.impl.ConversationUpdateEvent
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hook
import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID
class PreventMessageListAutoScroll : Feature("PreventMessageListAutoScroll", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
class PreventMessageListAutoScroll : Feature("PreventMessageListAutoScroll") {
private var openedConversationId: String? = null
private val focusedMessages = mutableMapOf<View, Long>()
private var firstFocusedMessageId: Long? = null
private val delayedMessageUpdates = mutableListOf<() -> Unit>()
override fun onActivityCreate() {
override fun init() {
if (!context.config.userInterface.preventMessageListAutoScroll.get()) return
context.event.subscribe(ConversationUpdateEvent::class) { event ->
val updatedMessage = event.messages.firstOrNull() ?: return@subscribe
if (openedConversationId != updatedMessage.messageDescriptor?.conversationId.toString()) return@subscribe
onNextActivityCreate {
context.event.subscribe(ConversationUpdateEvent::class) { event ->
val updatedMessage = event.messages.firstOrNull() ?: return@subscribe
if (openedConversationId != updatedMessage.messageDescriptor?.conversationId.toString()) return@subscribe
// cancel if the message is already in focus
if (focusedMessages.entries.any { entry -> entry.value == updatedMessage.messageDescriptor?.messageId && entry.key.isAttachedToWindow }) return@subscribe
// cancel if the message is already in focus
if (focusedMessages.entries.any { entry -> entry.value == updatedMessage.messageDescriptor?.messageId && entry.key.isAttachedToWindow }) return@subscribe
val conversationLastMessages = context.database.getMessagesFromConversationId(
openedConversationId.toString(),
4
) ?: return@subscribe
val conversationLastMessages = context.database.getMessagesFromConversationId(
openedConversationId.toString(),
4
) ?: return@subscribe
if (conversationLastMessages.none {
focusedMessages.entries.any { entry -> entry.value == it.clientMessageId.toLong() && entry.key.isAttachedToWindow }
}) {
synchronized(delayedMessageUpdates) {
if (firstFocusedMessageId == null) firstFocusedMessageId = conversationLastMessages.lastOrNull()?.clientMessageId?.toLong()
delayedMessageUpdates.add {
event.adapter.invokeOriginal()
if (conversationLastMessages.none {
focusedMessages.entries.any { entry -> entry.value == it.clientMessageId.toLong() && entry.key.isAttachedToWindow }
}) {
synchronized(delayedMessageUpdates) {
if (firstFocusedMessageId == null) firstFocusedMessageId = conversationLastMessages.lastOrNull()?.clientMessageId?.toLong()
delayedMessageUpdates.add {
event.adapter.invokeOriginal()
}
}
event.adapter.setResult(null)
}
}
context.classCache.conversationManager.apply {
hook("enterConversation", HookStage.BEFORE) { param ->
openedConversationId = SnapUUID(param.arg(0)).toString()
}
hook("exitConversation", HookStage.BEFORE) {
openedConversationId = null
firstFocusedMessageId = null
synchronized(focusedMessages) {
focusedMessages.clear()
}
synchronized(delayedMessageUpdates) {
delayedMessageUpdates.clear()
}
}
event.adapter.setResult(null)
}
}
context.classCache.conversationManager.apply {
hook("enterConversation", HookStage.BEFORE) { param ->
openedConversationId = SnapUUID(param.arg(0)).toString()
}
hook("exitConversation", HookStage.BEFORE) {
openedConversationId = null
firstFocusedMessageId = null
synchronized(focusedMessages) {
focusedMessages.clear()
}
synchronized(delayedMessageUpdates) {
delayedMessageUpdates.clear()
}
}
}
context.event.subscribe(BindViewEvent::class) { event ->
event.chatMessage { conversationId, messageId ->
if (conversationId != openedConversationId) return@chatMessage
synchronized(focusedMessages) {
focusedMessages[event.view] = messageId.toLong()
}
context.event.subscribe(BindViewEvent::class) { event ->
event.chatMessage { conversationId, messageId ->
if (conversationId != openedConversationId) return@chatMessage
synchronized(focusedMessages) {
focusedMessages[event.view] = messageId.toLong()
}
if (delayedMessageUpdates.isNotEmpty() && focusedMessages.entries.any { entry -> entry.value == firstFocusedMessageId && entry.key.isAttachedToWindow }) {
delayedMessageUpdates.apply {
synchronized(this) {
removeIf { it(); true }
firstFocusedMessageId = null
if (delayedMessageUpdates.isNotEmpty() && focusedMessages.entries.any { entry -> entry.value == firstFocusedMessageId && entry.key.isAttachedToWindow }) {
delayedMessageUpdates.apply {
synchronized(this) {
removeIf { it(); true }
firstFocusedMessageId = null
}
}
}
}

View File

@@ -1,17 +1,18 @@
package me.rhunk.snapenhance.core.features.impl.tweaks
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.util.dataBuilder
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hookConstructor
class RemoveGroupsLockedStatus : Feature("Remove Groups Locked Status", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
override fun onActivityCreate() {
class RemoveGroupsLockedStatus : Feature("Remove Groups Locked Status") {
override fun init() {
if (!context.config.messaging.removeGroupsLockedStatus.get()) return
context.classCache.conversation.hookConstructor(HookStage.AFTER) { param ->
param.thisObject<Any>().dataBuilder {
set("mLockedState", "UNLOCKED")
onNextActivityCreate(defer = true) {
context.classCache.conversation.hookConstructor(HookStage.AFTER) { param ->
param.thisObject<Any>().dataBuilder {
set("mLockedState", "UNLOCKED")
}
}
}
}

View File

@@ -5,14 +5,12 @@ import me.rhunk.snapenhance.common.data.MessagingRuleType
import me.rhunk.snapenhance.common.util.protobuf.ProtoEditor
import me.rhunk.snapenhance.common.util.protobuf.ProtoReader
import me.rhunk.snapenhance.core.event.events.impl.NativeUnaryCallEvent
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.features.MessagingRuleFeature
import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID
class UnsaveableMessages : MessagingRuleFeature(
"Unsaveable Messages",
MessagingRuleType.UNSAVEABLE_MESSAGES,
loadParams = FeatureLoadParams.INIT_SYNC
MessagingRuleType.UNSAVEABLE_MESSAGES
) {
override fun init() {
if (context.config.rules.getRuleState(MessagingRuleType.UNSAVEABLE_MESSAGES) == null) return

View File

@@ -2,11 +2,10 @@ package me.rhunk.snapenhance.core.features.impl.ui
import me.rhunk.snapenhance.common.config.impl.UserInterfaceTweaks
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import java.io.File
class ClientBootstrapOverride: Feature("ClientBootstrapOverride", loadParams = FeatureLoadParams.INIT_SYNC) {
class ClientBootstrapOverride: Feature("ClientBootstrapOverride") {
private val clientBootstrapFolder by lazy { File(context.androidContext.filesDir, "client-bootstrap") }

View File

@@ -34,7 +34,6 @@ import me.rhunk.snapenhance.common.scripting.ui.ScriptInterface
import me.rhunk.snapenhance.common.ui.createComposeAlertDialog
import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.features.impl.messaging.Messaging
import me.rhunk.snapenhance.core.util.ktx.getId
@@ -45,7 +44,7 @@ data class ComposableMenu(
val composable: @Composable (alertDialog: AlertDialog, conversationId: String) -> Unit,
)
class ConversationToolbox : Feature("Conversation Toolbox", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
class ConversationToolbox : Feature("Conversation Toolbox") {
private val composableList = mutableListOf<ComposableMenu>()
private val expandedComposableCache = mutableStateMapOf<String, Boolean>()
@@ -56,49 +55,51 @@ class ConversationToolbox : Feature("Conversation Toolbox", loadParams = Feature
}
@SuppressLint("SetTextI18n")
override fun onActivityCreate() {
val defaultInputBarId = context.resources.getId("default_input_bar")
override fun init() {
onNextActivityCreate {
val defaultInputBarId = context.resources.getId("default_input_bar")
context.event.subscribe(AddViewEvent::class) { event ->
if (event.view.id != defaultInputBarId) return@subscribe
if (composableList.isEmpty()) return@subscribe
context.event.subscribe(AddViewEvent::class) { event ->
if (event.view.id != defaultInputBarId) return@subscribe
if (composableList.isEmpty()) return@subscribe
(event.view as ViewGroup).addView(FrameLayout(event.view.context).apply {
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
(52 * context.resources.displayMetrics.density).toInt(),
).apply {
gravity = Gravity.BOTTOM
}
setPadding(25, 0, 25, 0)
addView(TextView(event.view.context).apply {
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
(event.view as ViewGroup).addView(FrameLayout(event.view.context).apply {
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
(52 * context.resources.displayMetrics.density).toInt(),
).apply {
gravity = Gravity.CENTER_VERTICAL
gravity = Gravity.BOTTOM
}
setOnClickListener {
openToolbox()
}
textSize = 21f
text = "\uD83E\uDDF0"
})
})
}
setPadding(25, 0, 25, 0)
context.scriptRuntime.eachModule {
val interfaceManager = getBinding(InterfaceManager::class)?.takeIf {
it.hasInterface(EnumScriptInterface.CONVERSATION_TOOLBOX)
} ?: return@eachModule
addComposable("\uD83D\uDCDC ${moduleInfo.displayName}") { alertDialog, conversationId ->
ScriptInterface(remember {
interfaceManager.buildInterface(EnumScriptInterface.CONVERSATION_TOOLBOX, mapOf(
"alertDialog" to alertDialog,
"conversationId" to conversationId,
))
} ?: return@addComposable)
addView(TextView(event.view.context).apply {
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
).apply {
gravity = Gravity.CENTER_VERTICAL
}
setOnClickListener {
openToolbox()
}
textSize = 21f
text = "\uD83E\uDDF0"
})
})
}
context.scriptRuntime.eachModule {
val interfaceManager = getBinding(InterfaceManager::class)?.takeIf {
it.hasInterface(EnumScriptInterface.CONVERSATION_TOOLBOX)
} ?: return@eachModule
addComposable("\uD83D\uDCDC ${moduleInfo.displayName}") { alertDialog, conversationId ->
ScriptInterface(remember {
interfaceManager.buildInterface(EnumScriptInterface.CONVERSATION_TOOLBOX, mapOf(
"alertDialog" to alertDialog,
"conversationId" to conversationId,
))
} ?: return@addComposable)
}
}
}
}

View File

@@ -1,53 +1,54 @@
package me.rhunk.snapenhance.core.features.impl.ui
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
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.StreaksExpirationMapper
import kotlin.time.Duration.Companion.milliseconds
class CustomStreaksExpirationFormat: Feature("CustomStreaksExpirationFormat", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
class CustomStreaksExpirationFormat: Feature("CustomStreaksExpirationFormat") {
private fun Long.padZero(): String {
return this.toString().padStart(2, '0')
}
override fun onActivityCreate() {
val expirationFormat by context.config.experimental.customStreaksExpirationFormat
if (expirationFormat.isNotEmpty() || context.config.userInterface.streakExpirationInfo.get()) {
context.mappings.useMapper(StreaksExpirationMapper::class) {
runCatching {
simpleStreaksFormatterClass.getAsClass()?.hook(formatSimpleStreaksTextMethod.get() ?: return@useMapper, HookStage.BEFORE) { param ->
param.setResult(null)
override fun init() {
onNextActivityCreate {
val expirationFormat by context.config.experimental.customStreaksExpirationFormat
if (expirationFormat.isNotEmpty() || context.config.userInterface.streakExpirationInfo.get()) {
context.mappings.useMapper(StreaksExpirationMapper::class) {
runCatching {
simpleStreaksFormatterClass.getAsClass()?.hook(formatSimpleStreaksTextMethod.get() ?: return@useMapper, HookStage.BEFORE) { param ->
param.setResult(null)
}
}.onFailure {
context.log.warn("Failed to hook simpleStreaksFormatterClass : " + it.message)
}
}.onFailure {
context.log.warn("Failed to hook simpleStreaksFormatterClass : " + it.message)
}
}
}
if (expirationFormat.isEmpty()) return
if (expirationFormat.isEmpty()) return@onNextActivityCreate
context.mappings.useMapper(StreaksExpirationMapper::class) {
streaksFormatterClass.getAsClass()?.hook(formatStreaksTextMethod.get() ?: return@useMapper, HookStage.AFTER) { param ->
val streaksCount = param.argNullable(2) ?: 0
val streaksExpiration = param.argNullable<Any>(3) ?: return@hook
context.mappings.useMapper(StreaksExpirationMapper::class) {
streaksFormatterClass.getAsClass()?.hook(formatStreaksTextMethod.get() ?: return@useMapper, HookStage.AFTER) { param ->
val streaksCount = param.argNullable(2) ?: 0
val streaksExpiration = param.argNullable<Any>(3) ?: return@hook
val hourGlassTimeRemaining = streaksExpiration.getObjectField(hourGlassTimeRemainingField.get() ?: return@hook) as? Long ?: return@hook
val expirationTime = streaksExpiration.getObjectField(expirationTimeField.get() ?: return@hook) as? Long ?: return@hook
val delta = (expirationTime - System.currentTimeMillis()).milliseconds
val hourGlassTimeRemaining = streaksExpiration.getObjectField(hourGlassTimeRemainingField.get() ?: return@hook) as? Long ?: return@hook
val expirationTime = streaksExpiration.getObjectField(expirationTimeField.get() ?: return@hook) as? Long ?: return@hook
val delta = (expirationTime - System.currentTimeMillis()).milliseconds
val hourGlassEmoji = if (delta.inWholeMilliseconds in 1..hourGlassTimeRemaining) if (expirationTime % 2 == 0L) "\u23F3" else "\u231B" else ""
val hourGlassEmoji = if (delta.inWholeMilliseconds in 1..hourGlassTimeRemaining) if (expirationTime % 2 == 0L) "\u23F3" else "\u231B" else ""
param.setResult(expirationFormat
.replace("%c", streaksCount.toString())
.replace("%e", hourGlassEmoji)
.replace("%d", delta.inWholeDays.toString())
.replace("%h", (delta.inWholeHours % 24).padZero())
.replace("%m", (delta.inWholeMinutes % 60).padZero())
.replace("%s", (delta.inWholeSeconds % 60).padZero())
.replace("%w", delta.toString())
)
param.setResult(expirationFormat
.replace("%c", streaksCount.toString())
.replace("%e", hourGlassEmoji)
.replace("%d", delta.inWholeDays.toString())
.replace("%h", (delta.inWholeHours % 24).padZero())
.replace("%m", (delta.inWholeMinutes % 60).padZero())
.replace("%s", (delta.inWholeSeconds % 60).padZero())
.replace("%w", delta.toString())
)
}
}
}
}

View File

@@ -7,18 +7,17 @@ import android.util.TypedValue
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.ui.graphics.toArgb
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.Hooker
import me.rhunk.snapenhance.core.util.hook.hook
import me.rhunk.snapenhance.core.util.ktx.getIdentifier
class CustomizeUI: Feature("Customize UI", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
class CustomizeUI: Feature("Customize UI") {
private fun getAttribute(name: String): Int {
return context.resources.getIdentifier(name, "attr")
}
override fun onActivityCreate() {
override fun init() {
val customizeUIConfig = context.config.userInterface.customizeUi
val themePicker = customizeUIConfig.themePicker.getNullable() ?: return
val colorsConfig = context.config.userInterface.customizeUi.colors

View File

@@ -2,17 +2,18 @@ package me.rhunk.snapenhance.core.features.impl.ui
import android.view.KeyEvent
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hook
class DefaultVolumeControls : Feature("Default Volume Controls", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
override fun onActivityCreate() {
class DefaultVolumeControls : Feature("Default Volume Controls") {
override fun init() {
if (!context.config.global.defaultVolumeControls.get()) return
context.mainActivity!!::class.java.hook("onKeyDown", HookStage.BEFORE) { param ->
val keyCode = param.arg<Int>(0)
if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
param.setResult(false)
onNextActivityCreate { activity ->
activity::class.java.hook("onKeyDown", HookStage.BEFORE) { param ->
val keyCode = param.arg<Int>(0)
if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
param.setResult(false)
}
}
}
}

View File

@@ -4,59 +4,60 @@ import android.view.View
import android.widget.TextView
import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.ui.children
import me.rhunk.snapenhance.core.ui.triggerRootCloseTouchEvent
import me.rhunk.snapenhance.core.util.ktx.getId
import me.rhunk.snapenhance.core.util.ktx.getIdentifier
import java.util.regex.Pattern
class DisableConfirmationDialogs : Feature("Disable Confirmation Dialogs", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
override fun onActivityCreate() {
val disableConfirmationDialogs = context.config.global.disableConfirmationDialogs.get().takeIf { it.isNotEmpty() } ?: return
val dialogContent = context.resources.getId("dialog_content")
val alertDialogTitle = context.resources.getId("alert_dialog_title")
class DisableConfirmationDialogs : Feature("Disable Confirmation Dialogs") {
override fun init() {
onNextActivityCreate {
val disableConfirmationDialogs = context.config.global.disableConfirmationDialogs.get().takeIf { it.isNotEmpty() } ?: return@onNextActivityCreate
val dialogContent = context.resources.getId("dialog_content")
val alertDialogTitle = context.resources.getId("alert_dialog_title")
val questions = listOf(
"erase_message" to "erase_learn_more_dialog_title",
"erase_message" to "erase_dialog_title",
"erase_message" to "snap_erase_dialog_title",
"erase_message" to "snap_erase_learn_more_dialog_title",
"remove_friend" to "action_menu_remove_friend_question",
"block_friend" to "action_menu_block_friend_question",
"ignore_friend" to "action_menu_ignore_friend_question",
"hide_friend" to "action_menu_hide_friend_question",
"hide_conversation" to "hide_or_block_clear_conversation_dialog_title",
"clear_conversation" to "action_menu_clear_conversation_dialog_title"
).map { pair ->
pair.first to runCatching {
Pattern.compile(
context.resources.getString(context.resources.getIdentifier(pair.second, "string"))
.split("%s").joinToString(".*") {
Pattern.quote(it)
}, Pattern.CASE_INSENSITIVE)
}.onFailure {
context.log.error("Failed to compile regex for ${pair.second}", it)
}.getOrNull()
}
val questions = listOf(
"erase_message" to "erase_learn_more_dialog_title",
"erase_message" to "erase_dialog_title",
"erase_message" to "snap_erase_dialog_title",
"erase_message" to "snap_erase_learn_more_dialog_title",
"remove_friend" to "action_menu_remove_friend_question",
"block_friend" to "action_menu_block_friend_question",
"ignore_friend" to "action_menu_ignore_friend_question",
"hide_friend" to "action_menu_hide_friend_question",
"hide_conversation" to "hide_or_block_clear_conversation_dialog_title",
"clear_conversation" to "action_menu_clear_conversation_dialog_title"
).map { pair ->
pair.first to runCatching {
Pattern.compile(
context.resources.getString(context.resources.getIdentifier(pair.second, "string"))
.split("%s").joinToString(".*") {
Pattern.quote(it)
}, Pattern.CASE_INSENSITIVE)
}.onFailure {
context.log.error("Failed to compile regex for ${pair.second}", it)
}.getOrNull()
}
context.event.subscribe(AddViewEvent::class) { event ->
if (event.parent.id != dialogContent || !event.view::class.java.name.endsWith("SnapButtonView")) return@subscribe
context.event.subscribe(AddViewEvent::class) { event ->
if (event.parent.id != dialogContent || !event.view::class.java.name.endsWith("SnapButtonView")) return@subscribe
val dialogTitle = event.parent.findViewById<TextView>(alertDialogTitle)?.text?.toString() ?: return@subscribe
if (event.parent.children().count { it::class.java.name.endsWith("SnapButtonView") } != 0) return@subscribe
val dialogTitle = event.parent.findViewById<TextView>(alertDialogTitle)?.text?.toString() ?: return@subscribe
if (event.parent.children().count { it::class.java.name.endsWith("SnapButtonView") } != 0) return@subscribe
questions.forEach { (key, value) ->
if (!disableConfirmationDialogs.contains(key)) return@forEach
questions.forEach { (key, value) ->
if (!disableConfirmationDialogs.contains(key)) return@forEach
if (value?.matcher(dialogTitle)?.matches() == true) {
event.parent.visibility = View.INVISIBLE
event.parent.post {
event.view.callOnClick()
if (value?.matcher(dialogTitle)?.matches() == true) {
event.parent.visibility = View.INVISIBLE
event.parent.post {
event.view.callOnClick()
}
event.parent.postDelayed({
context.mainActivity!!.triggerRootCloseTouchEvent()
}, 400)
}
event.parent.postDelayed({
context.mainActivity!!.triggerRootCloseTouchEvent()
}, 400)
}
}
}

View File

@@ -5,30 +5,31 @@ import android.text.InputType
import android.widget.EditText
import android.widget.TextView
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hook
import me.rhunk.snapenhance.core.util.hook.hookConstructor
class EditTextOverride : Feature("Edit Text Override", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
override fun onActivityCreate() {
class EditTextOverride : Feature("Edit Text Override") {
override fun init() {
val editTextOverride by context.config.userInterface.editTextOverride
if (editTextOverride.isEmpty()) return
if (editTextOverride.contains("bypass_text_input_limit")) {
TextView::class.java.getMethod("setFilters", Array<InputFilter>::class.java)
.hook(HookStage.BEFORE) { param ->
param.setArg(0, param.arg<Array<InputFilter>>(0).filter {
it !is InputFilter.LengthFilter
}.toTypedArray())
}
}
onNextActivityCreate {
if (editTextOverride.contains("bypass_text_input_limit")) {
TextView::class.java.getMethod("setFilters", Array<InputFilter>::class.java)
.hook(HookStage.BEFORE) { param ->
param.setArg(0, param.arg<Array<InputFilter>>(0).filter {
it !is InputFilter.LengthFilter
}.toTypedArray())
}
}
if (editTextOverride.contains("multi_line_chat_input")) {
findClass("com.snap.messaging.chat.features.input.InputBarEditText").apply {
hookConstructor(HookStage.AFTER) { param ->
val editText = param.thisObject<EditText>()
editText.inputType = editText.inputType or InputType.TYPE_TEXT_FLAG_MULTI_LINE
if (editTextOverride.contains("multi_line_chat_input")) {
findClass("com.snap.messaging.chat.features.input.InputBarEditText").apply {
hookConstructor(HookStage.AFTER) { param ->
val editText = param.thisObject<EditText>()
editText.inputType = editText.inputType or InputType.TYPE_TEXT_FLAG_MULTI_LINE
}
}
}
}

View File

@@ -8,6 +8,7 @@ import android.graphics.drawable.shapes.Shape
import android.text.TextPaint
import android.view.View
import android.view.ViewGroup
import androidx.core.content.res.use
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
@@ -17,7 +18,6 @@ import me.rhunk.snapenhance.common.util.protobuf.ProtoReader
import me.rhunk.snapenhance.core.event.events.impl.BindViewEvent
import me.rhunk.snapenhance.core.event.events.impl.BuildMessageEvent
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.features.impl.experiments.EndToEndEncryption
import me.rhunk.snapenhance.core.ui.addForegroundDrawable
import me.rhunk.snapenhance.core.ui.removeForegroundDrawable
@@ -29,7 +29,7 @@ import me.rhunk.snapenhance.core.wrapper.impl.getMessageText
import java.util.WeakHashMap
import kotlin.math.absoluteValue
class FriendFeedMessagePreview : Feature("FriendFeedMessagePreview", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
class FriendFeedMessagePreview : Feature("FriendFeedMessagePreview") {
private val endToEndEncryption by lazy { context.feature(EndToEndEncryption::class) }
@OptIn(ExperimentalCoroutinesApi::class)
private val coroutineDispatcher = Dispatchers.IO.limitedParallelism(1)
@@ -39,7 +39,7 @@ class FriendFeedMessagePreview : Feature("FriendFeedMessagePreview", loadParams
private val sigColorTextPrimary by lazy {
context.mainActivity!!.theme.obtainStyledAttributes(
intArrayOf(context.resources.getIdentifier("sigColorTextPrimary", "attr"))
).getColor(0, 0)
).use { it.getColor(0, 0) }
}
private val cachedLayouts = WeakHashMap<String, View>()
@@ -73,82 +73,84 @@ class FriendFeedMessagePreview : Feature("FriendFeedMessagePreview", loadParams
}
}
override fun onActivityCreate() {
override fun init() {
if (setting.globalState != true) return
val ffItemId = context.resources.getId("ff_item")
onNextActivityCreate {
val ffItemId = context.resources.getId("ff_item")
val secondaryTextSize = context.resources.getDimens("ff_feed_cell_secondary_text_size").toFloat()
val ffSdlAvatarMargin = context.resources.getDimens("ff_sdl_avatar_margin")
val ffSdlAvatarSize = context.resources.getDimens("ff_sdl_avatar_size")
val ffSdlPrimaryTextStartMargin = context.resources.getDimens("ff_sdl_primary_text_start_margin").toFloat()
val secondaryTextSize = context.resources.getDimens("ff_feed_cell_secondary_text_size").toFloat()
val ffSdlAvatarMargin = context.resources.getDimens("ff_sdl_avatar_margin")
val ffSdlAvatarSize = context.resources.getDimens("ff_sdl_avatar_size")
val ffSdlPrimaryTextStartMargin = context.resources.getDimens("ff_sdl_primary_text_start_margin").toFloat()
val feedEntryHeight = ffSdlAvatarSize + ffSdlAvatarMargin * 2 + (4 * context.resources.displayMetrics.density).toInt()
val separatorHeight = (context.resources.displayMetrics.density * 2).toInt()
val avenirNextMedium = context.resources.getFont(context.resources.getIdentifier("avenir_next_medium", "font"))
val textPaint = TextPaint().apply {
textSize = secondaryTextSize
typeface = avenirNextMedium
}
val feedEntryHeight = ffSdlAvatarSize + ffSdlAvatarMargin * 2 + (4 * context.resources.displayMetrics.density).toInt()
val separatorHeight = (context.resources.displayMetrics.density * 2).toInt()
val avenirNextMedium = context.resources.getFont(context.resources.getIdentifier("avenir_next_medium", "font"))
val textPaint = TextPaint().apply {
textSize = secondaryTextSize
typeface = avenirNextMedium
}
context.event.subscribe(BuildMessageEvent::class) { param ->
val conversationId = param.message.messageDescriptor?.conversationId?.toString() ?: return@subscribe
val cachedView = cachedLayouts[conversationId] ?: return@subscribe
context.coroutineScope.launch {
fetchMessages(conversationId) {
cachedView.postInvalidateDelayed(100L)
context.event.subscribe(BuildMessageEvent::class) { param ->
val conversationId = param.message.messageDescriptor?.conversationId?.toString() ?: return@subscribe
val cachedView = cachedLayouts[conversationId] ?: return@subscribe
context.coroutineScope.launch {
fetchMessages(conversationId) {
cachedView.postInvalidateDelayed(100L)
}
}
}
}
context.event.subscribe(BindViewEvent::class) { param ->
param.friendFeedItem { conversationId ->
val frameLayout = param.view as ViewGroup
val ffItem = frameLayout.findViewById<View>(ffItemId)
context.event.subscribe(BindViewEvent::class) { param ->
param.friendFeedItem { conversationId ->
val frameLayout = param.view as ViewGroup
val ffItem = frameLayout.findViewById<View>(ffItemId)
context.coroutineScope.launch(coroutineDispatcher) {
withContext(Dispatchers.Main) {
cachedLayouts.remove(conversationId)
frameLayout.removeForegroundDrawable("ffItem")
}
fetchMessages(conversationId) {
var maxTextHeight = 0
val previewContainerHeight = messageCache[conversationId]?.sumOf { msg ->
val rect = Rect()
textPaint.getTextBounds(msg, 0, msg.length, rect)
rect.height().also {
if (it > maxTextHeight) maxTextHeight = it
}.plus(separatorHeight)
} ?: run {
ffItem.layoutParams = ffItem.layoutParams.apply {
height = ViewGroup.LayoutParams.MATCH_PARENT
}
return@fetchMessages
context.coroutineScope.launch(coroutineDispatcher) {
withContext(Dispatchers.Main) {
cachedLayouts.remove(conversationId)
frameLayout.removeForegroundDrawable("ffItem")
}
ffItem.layoutParams = ffItem.layoutParams.apply {
height = feedEntryHeight + previewContainerHeight + separatorHeight
}
cachedLayouts[conversationId] = frameLayout
frameLayout.addForegroundDrawable("ffItem", ShapeDrawable(object: Shape() {
override fun draw(canvas: Canvas, paint: Paint) {
val offsetY = canvas.height.toFloat() - previewContainerHeight
paint.textSize = secondaryTextSize
paint.color = sigColorTextPrimary
paint.typeface = avenirNextMedium
messageCache[conversationId]?.forEachIndexed { index, messageString ->
canvas.drawText(messageString,
feedEntryHeight + ffSdlPrimaryTextStartMargin,
offsetY + index * maxTextHeight,
paint
)
fetchMessages(conversationId) {
var maxTextHeight = 0
val previewContainerHeight = messageCache[conversationId]?.sumOf { msg ->
val rect = Rect()
textPaint.getTextBounds(msg, 0, msg.length, rect)
rect.height().also {
if (it > maxTextHeight) maxTextHeight = it
}.plus(separatorHeight)
} ?: run {
ffItem.layoutParams = ffItem.layoutParams.apply {
height = ViewGroup.LayoutParams.MATCH_PARENT
}
return@fetchMessages
}
}))
ffItem.layoutParams = ffItem.layoutParams.apply {
height = feedEntryHeight + previewContainerHeight + separatorHeight
}
cachedLayouts[conversationId] = frameLayout
frameLayout.addForegroundDrawable("ffItem", ShapeDrawable(object: Shape() {
override fun draw(canvas: Canvas, paint: Paint) {
val offsetY = canvas.height.toFloat() - previewContainerHeight
paint.textSize = secondaryTextSize
paint.color = sigColorTextPrimary
paint.typeface = avenirNextMedium
messageCache[conversationId]?.forEachIndexed { index, messageString ->
canvas.drawText(messageString,
feedEntryHeight + ffSdlPrimaryTextStartMargin,
offsetY + index * maxTextHeight,
paint
)
}
}
}))
}
}
}
}

View File

@@ -2,7 +2,7 @@ package me.rhunk.snapenhance.core.features.impl.ui
import me.rhunk.snapenhance.common.data.MessagingRuleType
import me.rhunk.snapenhance.common.data.RuleState
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.features.MessagingRuleFeature
import me.rhunk.snapenhance.core.util.dataBuilder
import me.rhunk.snapenhance.core.util.hook.HookStage
@@ -11,7 +11,7 @@ import me.rhunk.snapenhance.core.util.ktx.getObjectField
import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID
import me.rhunk.snapenhance.mapper.impl.CallbackMapper
class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType = MessagingRuleType.HIDE_FRIEND_FEED, loadParams = FeatureLoadParams.INIT_SYNC) {
class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType = MessagingRuleType.HIDE_FRIEND_FEED) {
private fun createDeletedFeedEntry(conversationIdInstance: Any) = findClass("com.snapchat.client.messaging.DeletedFeedEntry").dataBuilder {
from("mFeedEntryIdentifier") {
set("mConversationId", conversationIdInstance)

View File

@@ -1,22 +1,23 @@
package me.rhunk.snapenhance.core.features.impl.ui
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hookConstructor
import me.rhunk.snapenhance.core.util.ktx.setObjectField
import me.rhunk.snapenhance.mapper.impl.FriendingDataSourcesMapper
class HideQuickAddFriendFeed : Feature("HideQuickAddFriendFeed", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
override fun onActivityCreate() {
class HideQuickAddFriendFeed : Feature("HideQuickAddFriendFeed") {
override fun init() {
if (!context.config.userInterface.hideQuickAddFriendFeed.get()) return
context.mappings.useMapper(FriendingDataSourcesMapper::class) {
classReference.getAsClass()?.hookConstructor(HookStage.AFTER) { param ->
param.thisObject<Any>().setObjectField(
quickAddSourceListField.get()!!,
arrayListOf<Any>()
)
onNextActivityCreate {
context.mappings.useMapper(FriendingDataSourcesMapper::class) {
classReference.getAsClass()?.hookConstructor(HookStage.AFTER) { param ->
param.thisObject<Any>().setObjectField(
quickAddSourceListField.get()!!,
arrayListOf<Any>()
)
}
}
}
}

View File

@@ -1,7 +1,6 @@
package me.rhunk.snapenhance.core.features.impl.ui
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.features.impl.messaging.Messaging
import me.rhunk.snapenhance.core.util.dataBuilder
import me.rhunk.snapenhance.core.util.hook.HookStage
@@ -11,7 +10,7 @@ import me.rhunk.snapenhance.core.util.ktx.getObjectFieldOrNull
import me.rhunk.snapenhance.core.util.ktx.setObjectField
import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID
class HideStreakRestore : Feature("HideStreakRestore", loadParams = FeatureLoadParams.INIT_SYNC) {
class HideStreakRestore : Feature("HideStreakRestore") {
override fun init() {
if (!context.config.userInterface.hideStreakRestore.get()) return

View File

@@ -26,119 +26,120 @@ import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState
import me.rhunk.snapenhance.common.util.protobuf.ProtoReader
import me.rhunk.snapenhance.core.event.events.impl.BindViewEvent
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.ui.AppleLogo
import me.rhunk.snapenhance.core.ui.removeForegroundDrawable
import kotlin.random.Random
class MessageIndicators : Feature("Message Indicators", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
override fun onActivityCreate() {
class MessageIndicators : Feature("Message Indicators") {
override fun init() {
val messageIndicatorsConfig = context.config.userInterface.messageIndicators.getNullable() ?: return
if (messageIndicatorsConfig.isEmpty()) return
val messageInfoTag = Random.nextLong().toString()
val appleLogo = AppleLogo
onNextActivityCreate {
val appleLogo = AppleLogo
context.event.subscribe(BindViewEvent::class) { event ->
event.chatMessage { _, messageId ->
val parentLinearLayout = event.view.parent as? ViewGroup ?: return@subscribe
parentLinearLayout.findViewWithTag<View>(messageInfoTag)?.let { parentLinearLayout.removeView(it) }
context.event.subscribe(BindViewEvent::class) { event ->
event.chatMessage { _, messageId ->
val parentLinearLayout = event.view.parent as? ViewGroup ?: return@subscribe
parentLinearLayout.findViewWithTag<View>(messageInfoTag)?.let { parentLinearLayout.removeView(it) }
event.view.removeForegroundDrawable("messageIndicators")
event.view.removeForegroundDrawable("messageIndicators")
val message = context.database.getConversationMessageFromId(messageId.toLong()) ?: return@chatMessage
if (message.contentType != ContentType.SNAP.id && message.contentType != ContentType.EXTERNAL_MEDIA.id) return@chatMessage
val reader = ProtoReader(message.messageContent ?: return@chatMessage)
val message = context.database.getConversationMessageFromId(messageId.toLong()) ?: return@chatMessage
if (message.contentType != ContentType.SNAP.id && message.contentType != ContentType.EXTERNAL_MEDIA.id) return@chatMessage
val reader = ProtoReader(message.messageContent ?: return@chatMessage)
createComposeView(event.view.context) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(50.dp)
.padding(top = 4.dp, end = 1.dp),
contentAlignment = Alignment.TopEnd
) {
val hasEncryption by rememberAsyncMutableState(defaultValue = false) {
reader.getByteArray(4, 3, 3) != null || reader.containsPath(3, 99, 3)
}
val sentFromIosDevice by rememberAsyncMutableState(defaultValue = false) {
if (reader.containsPath(4, 4, 3)) !reader.containsPath(4, 4, 3, 3, 17) else reader.getVarInt(4, 4, 11, 17, 7) != null
}
val sentFromWebApp by rememberAsyncMutableState(defaultValue = false) {
reader.getVarInt(4, 4, *(if (reader.containsPath(4, 4, 3)) intArrayOf(3, 3, 22, 1) else intArrayOf(11, 22, 1))) == 7L
}
val sentWithLocation by rememberAsyncMutableState(defaultValue = false) {
reader.getVarInt(4, 4, 11, 17, 5) != null
}
val sentUsingOvfEditor by rememberAsyncMutableState(defaultValue = false) {
(reader.getString(4, 4, 11, 12, 1) ?: reader.getString(4, 4, 11, 13, 4, 1, 2, 12, 20, 1)) == "c13129f7-fe4a-44c4-9b9d-e0b26fee8f82"
}
val sentUsingDirectorMode by rememberAsyncMutableState(defaultValue = false) {
reader.followPath(4, 4, 11, 28)?.let {
(it.getVarInt(1) to it.getVarInt(2)) == (0L to 0L)
} == true || reader.getByteArray(4, 4, 11, 13, 4, 1, 2, 12, 27, 1) != null
}
Row(
verticalAlignment = Alignment.CenterVertically
createComposeView(event.view.context) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(50.dp)
.padding(top = 4.dp, end = 1.dp),
contentAlignment = Alignment.TopEnd
) {
if (sentWithLocation && messageIndicatorsConfig.contains("location_indicator")) {
Image(
imageVector = Icons.Default.LocationOn,
colorFilter = ColorFilter.tint(Color.Green),
contentDescription = null,
modifier = Modifier.size(15.dp)
)
val hasEncryption by rememberAsyncMutableState(defaultValue = false) {
reader.getByteArray(4, 3, 3) != null || reader.containsPath(3, 99, 3)
}
if (messageIndicatorsConfig.contains("platform_indicator")) {
Image(
imageVector = when {
sentFromWebApp -> Icons.Default.Laptop
sentFromIosDevice -> appleLogo
else -> Icons.Default.Android
},
colorFilter = ColorFilter.tint(Color.Green),
contentDescription = null,
modifier = Modifier.size(15.dp)
)
val sentFromIosDevice by rememberAsyncMutableState(defaultValue = false) {
if (reader.containsPath(4, 4, 3)) !reader.containsPath(4, 4, 3, 3, 17) else reader.getVarInt(4, 4, 11, 17, 7) != null
}
if (hasEncryption && messageIndicatorsConfig.contains("encryption_indicator")) {
Image(
imageVector = Icons.Default.Lock,
colorFilter = ColorFilter.tint(Color.Green),
contentDescription = null,
modifier = Modifier.size(15.dp)
)
val sentFromWebApp by rememberAsyncMutableState(defaultValue = false) {
reader.getVarInt(4, 4, *(if (reader.containsPath(4, 4, 3)) intArrayOf(3, 3, 22, 1) else intArrayOf(11, 22, 1))) == 7L
}
if (sentUsingDirectorMode && messageIndicatorsConfig.contains("director_mode_indicator")) {
Image(
imageVector = Icons.Default.Edit,
colorFilter = ColorFilter.tint(Color.Red),
contentDescription = null,
modifier = Modifier.size(15.dp)
)
val sentWithLocation by rememberAsyncMutableState(defaultValue = false) {
reader.getVarInt(4, 4, 11, 17, 5) != null
}
if (sentUsingOvfEditor && messageIndicatorsConfig.contains("ovf_editor_indicator")) {
Text(
text = "OVF",
color = Color.Red,
fontWeight = FontWeight.ExtraBold,
fontSize = 10.sp,
)
val sentUsingOvfEditor by rememberAsyncMutableState(defaultValue = false) {
(reader.getString(4, 4, 11, 12, 1) ?: reader.getString(4, 4, 11, 13, 4, 1, 2, 12, 20, 1)) == "c13129f7-fe4a-44c4-9b9d-e0b26fee8f82"
}
val sentUsingDirectorMode by rememberAsyncMutableState(defaultValue = false) {
reader.followPath(4, 4, 11, 28)?.let {
(it.getVarInt(1) to it.getVarInt(2)) == (0L to 0L)
} == true || reader.getByteArray(4, 4, 11, 13, 4, 1, 2, 12, 27, 1) != null
}
Row(
verticalAlignment = Alignment.CenterVertically
) {
if (sentWithLocation && messageIndicatorsConfig.contains("location_indicator")) {
Image(
imageVector = Icons.Default.LocationOn,
colorFilter = ColorFilter.tint(Color.Green),
contentDescription = null,
modifier = Modifier.size(15.dp)
)
}
if (messageIndicatorsConfig.contains("platform_indicator")) {
Image(
imageVector = when {
sentFromWebApp -> Icons.Default.Laptop
sentFromIosDevice -> appleLogo
else -> Icons.Default.Android
},
colorFilter = ColorFilter.tint(Color.Green),
contentDescription = null,
modifier = Modifier.size(15.dp)
)
}
if (hasEncryption && messageIndicatorsConfig.contains("encryption_indicator")) {
Image(
imageVector = Icons.Default.Lock,
colorFilter = ColorFilter.tint(Color.Green),
contentDescription = null,
modifier = Modifier.size(15.dp)
)
}
if (sentUsingDirectorMode && messageIndicatorsConfig.contains("director_mode_indicator")) {
Image(
imageVector = Icons.Default.Edit,
colorFilter = ColorFilter.tint(Color.Red),
contentDescription = null,
modifier = Modifier.size(15.dp)
)
}
if (sentUsingOvfEditor && messageIndicatorsConfig.contains("ovf_editor_indicator")) {
Text(
text = "OVF",
color = Color.Red,
fontWeight = FontWeight.ExtraBold,
fontSize = 10.sp,
)
}
}
}
}.apply {
tag = messageInfoTag
addOnLayoutChangeListener { _, left, _, right, _, _, _, _, _ ->
layout(left, 0, right, 0)
}
setPadding(0, 0, 0, -(50 * event.view.resources.displayMetrics.density).toInt())
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
parentLinearLayout.addView(this)
}
}.apply {
tag = messageInfoTag
addOnLayoutChangeListener { _, left, _, right, _, _, _, _, _ ->
layout(left, 0, right, 0)
}
setPadding(0, 0, 0, -(50 * event.view.resources.displayMetrics.density).toInt())
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
parentLinearLayout.addView(this)
}
}
}

View File

@@ -4,9 +4,8 @@ import android.net.Uri
import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie
import me.rhunk.snapenhance.core.event.events.impl.NetworkApiRequestEvent
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
class OldBitmojiSelfie : Feature("OldBitmojiSelfie", loadParams = FeatureLoadParams.INIT_SYNC) {
class OldBitmojiSelfie : Feature("OldBitmojiSelfie") {
override fun init() {
val urlPrefixes = arrayOf("https://images.bitmoji.com/3d/render/", "https://cf-st.sc-cdn.net/3d/render/")
val oldBitmojiSelfie = context.config.userInterface.oldBitmojiSelfie.getNullable() ?: return

View File

@@ -2,7 +2,6 @@ package me.rhunk.snapenhance.core.features.impl.ui
import me.rhunk.snapenhance.common.data.MessagingRuleType
import me.rhunk.snapenhance.common.data.RuleState
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.features.MessagingRuleFeature
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.Hooker
@@ -12,8 +11,8 @@ import me.rhunk.snapenhance.core.util.ktx.getObjectField
import me.rhunk.snapenhance.core.util.ktx.setObjectField
import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID
class PinConversations : MessagingRuleFeature("PinConversations", MessagingRuleType.PIN_CONVERSATION, loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
override fun onActivityCreate() {
class PinConversations : MessagingRuleFeature("PinConversations", MessagingRuleType.PIN_CONVERSATION) {
override fun init() {
if (!context.config.messaging.unlimitedConversationPinning.get()) return
context.classCache.feedManager.hook("setPinnedConversationStatus", HookStage.BEFORE) { param ->

View File

@@ -10,7 +10,6 @@ import me.rhunk.snapenhance.common.data.ContentType
import me.rhunk.snapenhance.common.util.protobuf.ProtoReader
import me.rhunk.snapenhance.core.event.events.impl.BindViewEvent
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.ui.addForegroundDrawable
import me.rhunk.snapenhance.core.ui.removeForegroundDrawable
import me.rhunk.snapenhance.core.util.EvictingMap
@@ -22,7 +21,7 @@ import me.rhunk.snapenhance.core.util.media.PreviewUtils
import me.rhunk.snapenhance.mapper.impl.CallbackMapper
import java.io.File
class SnapPreview : Feature("SnapPreview", loadParams = FeatureLoadParams.INIT_SYNC or FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
class SnapPreview : Feature("SnapPreview") {
private val mediaFileCache = EvictingMap<String, File>(500) // mMediaId => mediaFile
private val bitmapCache = EvictingMap<String, Bitmap>(50) // filePath => bitmap
@@ -44,48 +43,46 @@ class SnapPreview : Feature("SnapPreview", loadParams = FeatureLoadParams.INIT_S
mediaFileCache[mediaId.substringAfter("-")] = File(filePath.toString())
}
}
}
@SuppressLint("DiscouragedApi")
override fun onActivityCreate() {
if (!isEnabled) return
val chatMediaCardHeight = context.resources.getDimens("chat_media_card_height")
val chatMediaCardSnapMargin = context.resources.getDimens("chat_media_card_snap_margin")
val chatMediaCardSnapMarginStartSdl = context.resources.getDimens("chat_media_card_snap_margin_start_sdl")
onNextActivityCreate {
val chatMediaCardHeight = context.resources.getDimens("chat_media_card_height")
val chatMediaCardSnapMargin = context.resources.getDimens("chat_media_card_snap_margin")
val chatMediaCardSnapMarginStartSdl = context.resources.getDimens("chat_media_card_snap_margin_start_sdl")
fun decodeMedia(file: File) = runCatching {
bitmapCache.getOrPut(file.absolutePath) {
PreviewUtils.resizeBitmap(
PreviewUtils.createPreviewFromFile(file) ?: return@runCatching null,
chatMediaCardHeight - chatMediaCardSnapMargin,
chatMediaCardHeight - chatMediaCardSnapMargin
)
}
}.getOrNull()
fun decodeMedia(file: File) = runCatching {
bitmapCache.getOrPut(file.absolutePath) {
PreviewUtils.resizeBitmap(
PreviewUtils.createPreviewFromFile(file) ?: return@runCatching null,
chatMediaCardHeight - chatMediaCardSnapMargin,
chatMediaCardHeight - chatMediaCardSnapMargin
)
}
}.getOrNull()
context.event.subscribe(BindViewEvent::class) { event ->
event.chatMessage { _, messageId ->
event.view.removeForegroundDrawable("snapPreview")
context.event.subscribe(BindViewEvent::class) { event ->
event.chatMessage { _, messageId ->
event.view.removeForegroundDrawable("snapPreview")
val message = context.database.getConversationMessageFromId(messageId.toLong()) ?: return@chatMessage
val messageReader = ProtoReader(message.messageContent ?: return@chatMessage)
val contentType = ContentType.fromMessageContainer(messageReader.followPath(4, 4))
val message = context.database.getConversationMessageFromId(messageId.toLong()) ?: return@chatMessage
val messageReader = ProtoReader(message.messageContent ?: return@chatMessage)
val contentType = ContentType.fromMessageContainer(messageReader.followPath(4, 4))
if (contentType != ContentType.SNAP || message.isSaved == 1) return@chatMessage
if (contentType != ContentType.SNAP || message.isSaved == 1) return@chatMessage
val mediaIdKey = messageReader.getString(4, 5, 1, 3, 2, 2) ?: return@chatMessage
val mediaIdKey = messageReader.getString(4, 5, 1, 3, 2, 2) ?: return@chatMessage
event.view.addForegroundDrawable("snapPreview", ShapeDrawable(object: Shape() {
override fun draw(canvas: Canvas, paint: Paint) {
val bitmap = mediaFileCache[mediaIdKey]?.let { decodeMedia(it) } ?: return
event.view.addForegroundDrawable("snapPreview", ShapeDrawable(object: Shape() {
override fun draw(canvas: Canvas, paint: Paint) {
val bitmap = mediaFileCache[mediaIdKey]?.let { decodeMedia(it) } ?: return
canvas.drawBitmap(bitmap,
canvas.width.toFloat() - bitmap.width - chatMediaCardSnapMarginStartSdl.toFloat() - chatMediaCardSnapMargin.toFloat(),
(canvas.height - bitmap.height) / 2f,
null
)
}
}))
canvas.drawBitmap(bitmap,
canvas.width.toFloat() - bitmap.width - chatMediaCardSnapMarginStartSdl.toFloat() - chatMediaCardSnapMargin.toFloat(),
(canvas.height - bitmap.height) / 2f,
null
)
}
}))
}
}
}
}

View File

@@ -7,47 +7,48 @@ 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.features.FeatureLoadParams
import me.rhunk.snapenhance.core.features.impl.messaging.Messaging
import me.rhunk.snapenhance.core.util.EvictingMap
import me.rhunk.snapenhance.core.util.ktx.getId
class SpotlightCommentsUsername : Feature("SpotlightCommentsUsername", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
class SpotlightCommentsUsername : Feature("SpotlightCommentsUsername") {
private val usernameCache = EvictingMap<String, String>(150)
@SuppressLint("SetTextI18n")
override fun onActivityCreate() {
override fun init() {
if (!context.config.global.spotlightCommentsUsername.get()) return
val messaging = context.feature(Messaging::class)
val commentsCreatorBadgeTimestampId = context.resources.getId("comments_creator_badge_timestamp")
onNextActivityCreate(defer = true) {
val messaging = context.feature(Messaging::class)
val commentsCreatorBadgeTimestampId = context.resources.getId("comments_creator_badge_timestamp")
context.event.subscribe(BindViewEvent::class) { event ->
val commentsCreatorBadgeTimestamp = event.view.findViewById<TextView>(commentsCreatorBadgeTimestampId) ?: return@subscribe
context.event.subscribe(BindViewEvent::class) { event ->
val commentsCreatorBadgeTimestamp = event.view.findViewById<TextView>(commentsCreatorBadgeTimestampId) ?: return@subscribe
val posterUserId = event.prevModel.toString().takeIf { it.startsWith("Comment") }
?.substringAfter("posterUserId=")?.substringBefore(",")?.substringBefore(")") ?: return@subscribe
val posterUserId = event.prevModel.toString().takeIf { it.startsWith("Comment") }
?.substringAfter("posterUserId=")?.substringBefore(",")?.substringBefore(")") ?: return@subscribe
fun setUsername(username: String) {
usernameCache[posterUserId] = username
if (commentsCreatorBadgeTimestamp.text.contains(username)) return
commentsCreatorBadgeTimestamp.text = " (${username})" + commentsCreatorBadgeTimestamp.text.toString()
}
fun setUsername(username: String) {
usernameCache[posterUserId] = username
if (commentsCreatorBadgeTimestamp.text.contains(username)) return
commentsCreatorBadgeTimestamp.text = " (${username})" + commentsCreatorBadgeTimestamp.text.toString()
}
usernameCache[posterUserId]?.let {
setUsername(it)
return@subscribe
}
usernameCache[posterUserId]?.let {
setUsername(it)
return@subscribe
}
context.coroutineScope.launch {
val username = runCatching {
messaging.fetchSnapchatterInfos(listOf(posterUserId)).firstOrNull()
}.onFailure {
context.log.error("Failed to fetch snapchatter info for user $posterUserId", it)
}.getOrNull()?.username ?: return@launch
context.coroutineScope.launch {
val username = runCatching {
messaging.fetchSnapchatterInfos(listOf(posterUserId)).firstOrNull()
}.onFailure {
context.log.error("Failed to fetch snapchatter info for user $posterUserId", it)
}.getOrNull()?.username ?: return@launch
withContext(Dispatchers.Main) {
setUsername(username)
withContext(Dispatchers.Main) {
setUsername(username)
}
}
}
}

View File

@@ -4,12 +4,12 @@ import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.drawable.ShapeDrawable
import android.graphics.drawable.shapes.Shape
import androidx.core.content.res.use
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.features.FeatureLoadParams
import me.rhunk.snapenhance.core.features.impl.spying.StealthMode
import me.rhunk.snapenhance.core.ui.addForegroundDrawable
import me.rhunk.snapenhance.core.ui.removeForegroundDrawable
@@ -17,7 +17,7 @@ import me.rhunk.snapenhance.core.util.EvictingMap
import me.rhunk.snapenhance.core.util.ktx.getDimens
import me.rhunk.snapenhance.core.util.ktx.getIdentifier
class StealthModeIndicator : Feature("StealthModeIndicator", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
class StealthModeIndicator : Feature("StealthModeIndicator") {
private val stealthMode by lazy { context.feature(StealthMode::class) }
private val listeners = EvictingMap<String, (Boolean) -> Unit>(100)
@@ -30,46 +30,48 @@ class StealthModeIndicator : Feature("StealthModeIndicator", loadParams = Featur
}
}
override fun onActivityCreate() {
override fun init() {
if (!context.config.userInterface.stealthModeIndicator.get()) return
val secondaryTextSize = context.resources.getDimens("ff_feed_cell_secondary_text_size").toFloat()
val sigColorTextPrimary = context.mainActivity!!.obtainStyledAttributes(
intArrayOf(context.resources.getIdentifier("sigColorTextPrimary", "attr"))
).use { it.getColor(0, 0) }
onNextActivityCreate {
val secondaryTextSize = context.resources.getDimens("ff_feed_cell_secondary_text_size").toFloat()
val sigColorTextPrimary = context.mainActivity!!.obtainStyledAttributes(
intArrayOf(context.resources.getIdentifier("sigColorTextPrimary", "attr"))
).use { it.getColor(0, 0) }
stealthMode.addStateListener { conversationId, state ->
runCatching {
listeners[conversationId]?.invoke(state)
}.onFailure {
context.log.error("Failed to update stealth mode indicator", it)
}
}
context.event.subscribe(BindViewEvent::class) { event ->
fun updateStealthIndicator(isStealth: Boolean = true) {
event.view.removeForegroundDrawable("stealthModeIndicator")
if (!isStealth || !event.view.isAttachedToWindow) return
event.view.addForegroundDrawable("stealthModeIndicator", ShapeDrawable(object : Shape() {
override fun draw(canvas: Canvas, paint: Paint) {
paint.textSize = secondaryTextSize
paint.color = sigColorTextPrimary
canvas.drawText(
"\uD83D\uDC7B",
0f,
canvas.height.toFloat() - secondaryTextSize / 2,
paint
)
}
}))
}
event.friendFeedItem { conversationId ->
listeners[conversationId] = addStateListener@{ stealth ->
updateStealthIndicator(stealth)
stealthMode.addStateListener { conversationId, state ->
runCatching {
listeners[conversationId]?.invoke(state)
}.onFailure {
context.log.error("Failed to update stealth mode indicator", it)
}
fetchStealthState(conversationId) { isStealth ->
updateStealthIndicator(isStealth)
}
context.event.subscribe(BindViewEvent::class) { event ->
fun updateStealthIndicator(isStealth: Boolean = true) {
event.view.removeForegroundDrawable("stealthModeIndicator")
if (!isStealth || !event.view.isAttachedToWindow) return
event.view.addForegroundDrawable("stealthModeIndicator", ShapeDrawable(object : Shape() {
override fun draw(canvas: Canvas, paint: Paint) {
paint.textSize = secondaryTextSize
paint.color = sigColorTextPrimary
canvas.drawText(
"\uD83D\uDC7B",
0f,
canvas.height.toFloat() - secondaryTextSize / 2,
paint
)
}
}))
}
event.friendFeedItem { conversationId ->
listeners[conversationId] = addStateListener@{ stealth ->
updateStealthIndicator(stealth)
}
fetchStealthState(conversationId) { isStealth ->
updateStealthIndicator(isStealth)
}
}
}
}

View File

@@ -10,13 +10,12 @@ import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent
import me.rhunk.snapenhance.core.event.events.impl.BindViewEvent
import me.rhunk.snapenhance.core.event.events.impl.LayoutInflateEvent
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.Hooker
import me.rhunk.snapenhance.core.util.hook.hook
import me.rhunk.snapenhance.core.util.ktx.getIdentifier
class UITweaks : Feature("UITweaks", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
class UITweaks : Feature("UITweaks") {
private val identifierCache = mutableMapOf<String, Int>()
fun getId(name: String, defType: String): Int {
@@ -47,7 +46,7 @@ class UITweaks : Feature("UITweaks", loadParams = FeatureLoadParams.ACTIVITY_CRE
}
}
override fun onActivityCreate() {
private fun onActivityCreate() {
val blockAds by context.config.global.blockAds
val hiddenElements by context.config.userInterface.hideUiComponents
val hideStorySuggestions by context.config.userInterface.hideStorySuggestions
@@ -172,4 +171,10 @@ class UITweaks : Feature("UITweaks", loadParams = FeatureLoadParams.ACTIVITY_CRE
}
}
}
override fun init() {
onNextActivityCreate {
onActivityCreate()
}
}
}

View File

@@ -19,6 +19,7 @@ import android.view.View
import android.view.ViewGroup
import android.widget.Switch
import android.widget.TextView
import androidx.core.content.res.use
import me.rhunk.snapenhance.core.SnapEnhance
import me.rhunk.snapenhance.core.util.ktx.getDimens
import me.rhunk.snapenhance.core.util.ktx.getDimensFloat
@@ -136,10 +137,10 @@ object ViewAppearanceHelper {
val sigColorTextPrimary = component.context.theme.obtainStyledAttributes(
intArrayOf(resources.getIdentifier("sigColorTextPrimary", "attr"))
).getColor(0, 0)
).use { it.getColor(0, 0) }
val sigColorBackgroundSurface = component.context.theme.obtainStyledAttributes(
intArrayOf(resources.getIdentifier("sigColorBackgroundSurface", "attr"))
).getColor(0, 0)
).use { it.getColor(0, 0) }
val actionSheetDefaultCellHeight = resources.getDimens("action_sheet_default_cell_height")
val actionSheetCornerRadius = resources.getDimensFloat("action_sheet_corner_radius")

View File

@@ -9,7 +9,6 @@ import android.widget.LinearLayout
import android.widget.ScrollView
import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.features.impl.COFOverride
import me.rhunk.snapenhance.core.features.impl.messaging.Messaging
import me.rhunk.snapenhance.core.ui.findParent
@@ -18,7 +17,7 @@ import me.rhunk.snapenhance.core.util.ktx.getIdentifier
import kotlin.reflect.KClass
@SuppressLint("DiscouragedApi")
class MenuViewInjector : Feature("MenuViewInjector", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) {
class MenuViewInjector : Feature("MenuViewInjector") {
private val menuMap by lazy {
arrayOf(
NewChatActionMenu(),
@@ -40,113 +39,115 @@ class MenuViewInjector : Feature("MenuViewInjector", loadParams = FeatureLoadPar
return menuMap[menuClass] as? T
}
override fun asyncOnActivityCreate() {
menuMap.forEach { it.value.init() }
override fun init() {
onNextActivityCreate(defer = true) {
menuMap.forEach { it.value.init() }
val messaging = context.feature(Messaging::class)
val messaging = context.feature(Messaging::class)
val actionSheetItemsContainerLayoutId = context.resources.getIdentifier("action_sheet_items_container", "id")
val actionMenuTitle = context.resources.getIdentifier("action_menu_title", "id")
val actionMenu = context.resources.getIdentifier("action_menu", "id")
val componentsHolder = context.resources.getIdentifier("components_holder", "id")
val feedNewChat = context.resources.getIdentifier("feed_new_chat", "id")
val contextMenuButtonIconView = context.resources.getIdentifier("context_menu_button_icon_view", "id")
val chatActionMenu = context.resources.getIdentifier("chat_action_menu", "id")
val actionSheetItemsContainerLayoutId = context.resources.getIdentifier("action_sheet_items_container", "id")
val actionMenuTitle = context.resources.getIdentifier("action_menu_title", "id")
val actionMenu = context.resources.getIdentifier("action_menu", "id")
val componentsHolder = context.resources.getIdentifier("components_holder", "id")
val feedNewChat = context.resources.getIdentifier("feed_new_chat", "id")
val contextMenuButtonIconView = context.resources.getIdentifier("context_menu_button_icon_view", "id")
val chatActionMenu = context.resources.getIdentifier("chat_action_menu", "id")
val hasV2ActionMenu = { context.feature(COFOverride::class).hasActionMenuV2 }
val hasV2ActionMenu = { context.feature(COFOverride::class).hasActionMenuV2 }
context.event.subscribe(AddViewEvent::class) { event ->
val originalAddView: (View) -> Unit = {
event.adapter.invokeOriginal(arrayOf(it, -1,
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
))
)
}
val viewGroup: ViewGroup = event.parent
val childView: View = event.view
menuMap[OperaContextActionMenu::class]!!.inject(viewGroup, childView, originalAddView)
if (event.view.id == actionSheetItemsContainerLayoutId) {
event.view.post {
if (event.parent.findParent(4) {
it.findViewById<View>(actionMenuTitle) != null
} == null) return@post
val views = mutableListOf<View>()
menuMap[FriendFeedInfoMenu::class]?.inject(event.parent, event.view) {
views.add(it)
}
views.reversed().forEach { (event.view as ViewGroup).addView(it, 0) }
}
}
if (childView.id == contextMenuButtonIconView) {
menuMap[OperaViewerIcons::class]!!.inject(viewGroup, childView, originalAddView)
}
if (event.parent.id == componentsHolder && childView.id == feedNewChat) {
menuMap[SettingsGearInjector::class]!!.inject(viewGroup, childView, originalAddView)
return@subscribe
}
if (viewGroup !is LinearLayout && childView.id == chatActionMenu && context.isDeveloper) {
event.view = LinearLayout(childView.context).apply {
orientation = LinearLayout.VERTICAL
addView(
(menuMap[NewChatActionMenu::class]!! as NewChatActionMenu).createDebugInfoView(childView.context)
)
addView(event.view)
}
}
if (childView.javaClass.name.endsWith("ChatActionMenuComponent") && hasV2ActionMenu()) {
(menuMap[NewChatActionMenu::class]!! as NewChatActionMenu).handle(event)
return@subscribe
}
if (viewGroup.javaClass.name.endsWith("ActionMenuChatItemContainer") && !hasV2ActionMenu()) {
if (viewGroup.parent == null || viewGroup.parent.parent == null) return@subscribe
menuMap[ChatActionMenu::class]!!.inject(viewGroup, childView, originalAddView)
return@subscribe
}
if (viewGroup !is LinearLayout && childView.id == actionMenu && messaging.lastFocusedConversationType == 1) {
val injectedLayout = LinearLayout(childView.context).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.BOTTOM
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
addView(childView)
}
event.parent.post {
injectedLayout.addView(ScrollView(injectedLayout.context).apply {
layoutParams = LinearLayout.LayoutParams(
context.event.subscribe(AddViewEvent::class) { event ->
val originalAddView: (View) -> Unit = {
event.adapter.invokeOriginal(arrayOf(it, -1,
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
weight = 1f;
setMargins(0, 100, 0, 0)
}
addView(LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
menuMap[FriendFeedInfoMenu::class]?.inject(event.parent, injectedLayout) { view ->
view.layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
setMargins(0, 5, 0, 5)
}
addView(view)
}
})
}, 0)
ViewGroup.LayoutParams.MATCH_PARENT
))
)
}
event.view = injectedLayout
val viewGroup: ViewGroup = event.parent
val childView: View = event.view
menuMap[OperaContextActionMenu::class]!!.inject(viewGroup, childView, originalAddView)
if (event.view.id == actionSheetItemsContainerLayoutId) {
event.view.post {
if (event.parent.findParent(4) {
it.findViewById<View>(actionMenuTitle) != null
} == null) return@post
val views = mutableListOf<View>()
menuMap[FriendFeedInfoMenu::class]?.inject(event.parent, event.view) {
views.add(it)
}
views.reversed().forEach { (event.view as ViewGroup).addView(it, 0) }
}
}
if (childView.id == contextMenuButtonIconView) {
menuMap[OperaViewerIcons::class]!!.inject(viewGroup, childView, originalAddView)
}
if (event.parent.id == componentsHolder && childView.id == feedNewChat) {
menuMap[SettingsGearInjector::class]!!.inject(viewGroup, childView, originalAddView)
return@subscribe
}
if (viewGroup !is LinearLayout && childView.id == chatActionMenu && context.isDeveloper) {
event.view = LinearLayout(childView.context).apply {
orientation = LinearLayout.VERTICAL
addView(
(menuMap[NewChatActionMenu::class]!! as NewChatActionMenu).createDebugInfoView(childView.context)
)
addView(event.view)
}
}
if (childView.javaClass.name.endsWith("ChatActionMenuComponent") && hasV2ActionMenu()) {
(menuMap[NewChatActionMenu::class]!! as NewChatActionMenu).handle(event)
return@subscribe
}
if (viewGroup.javaClass.name.endsWith("ActionMenuChatItemContainer") && !hasV2ActionMenu()) {
if (viewGroup.parent == null || viewGroup.parent.parent == null) return@subscribe
menuMap[ChatActionMenu::class]!!.inject(viewGroup, childView, originalAddView)
return@subscribe
}
if (viewGroup !is LinearLayout && childView.id == actionMenu && messaging.lastFocusedConversationType == 1) {
val injectedLayout = LinearLayout(childView.context).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.BOTTOM
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
addView(childView)
}
event.parent.post {
injectedLayout.addView(ScrollView(injectedLayout.context).apply {
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
weight = 1f;
setMargins(0, 100, 0, 0)
}
addView(LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
menuMap[FriendFeedInfoMenu::class]?.inject(event.parent, injectedLayout) { view ->
view.layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
setMargins(0, 5, 0, 5)
}
addView(view)
}
})
}, 0)
}
event.view = injectedLayout
}
}
}
}

View File

@@ -33,6 +33,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.res.use
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -74,12 +75,12 @@ class FriendFeedInfoMenu : AbstractMenu() {
private val sigColorTextPrimary by lazy {
context.androidContext.theme.obtainStyledAttributes(
intArrayOf(context.resources.getIdentifier("sigColorTextPrimary", "attr"))
).getColor(0, 0)
).use { it.getColor(0, 0) }
}
private val sigColorBackgroundSurface by lazy {
context.androidContext.theme.obtainStyledAttributes(
intArrayOf(context.resources.getIdentifier("sigColorBackgroundSurface", "attr"))
).getColor(0, 0)
).use { it.getColor(0, 0) }
}
private fun getImageDrawable(url: String): Drawable {

View File

@@ -21,6 +21,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.res.use
import me.rhunk.snapenhance.common.ui.createComposeView
import me.rhunk.snapenhance.core.features.impl.OperaViewerParamsOverride
import me.rhunk.snapenhance.core.features.impl.downloader.MediaDownloader
@@ -170,7 +171,7 @@ class OperaContextActionMenu : AbstractMenu() {
color = remember {
view.context.theme.obtainStyledAttributes(
intArrayOf(view.context.resources.getIdentifier("sigColorTextPrimary", "attr"))
).getColor(0, 0).let { Color(it) }
).use { Color(it.getColor(0, 0)) }
},
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()

View File

@@ -5,6 +5,7 @@ import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageView
import androidx.core.content.res.use
import me.rhunk.snapenhance.common.ui.OverlayType
import me.rhunk.snapenhance.core.ui.menu.AbstractMenu
import me.rhunk.snapenhance.core.util.ktx.getDimens
@@ -65,8 +66,8 @@ class SettingsGearInjector : AbstractMenu() {
gravity = android.view.Gravity.CENTER
}
setImageDrawable(context.resources.getDrawable("svg_settings_32x32", context.theme))
context.resources.getStyledAttributes("headerButtonOpaqueIconTint", context.theme).getColorStateList(0)?.let {
imageTintList = it
context.resources.getStyledAttributes("headerButtonOpaqueIconTint", context.theme).use {
imageTintList = it.getColorStateList(0)
}
})
})