mirror of
https://github.com/rhunk/SnapEnhance.git
synced 2025-06-13 05:37:48 +02:00
initial commit
This commit is contained in:
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
*.iml
|
||||||
|
.gradle
|
||||||
|
/local.properties
|
||||||
|
/.idea/
|
||||||
|
.DS_Store
|
||||||
|
/build
|
||||||
|
/captures
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx
|
||||||
|
local.properties
|
28
README.md
Normal file
28
README.md
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# SnapEnhance
|
||||||
|
A xposed mod to enhance the Snapchat experience
|
||||||
|
The project is currently in development, so expect bugs and crashes. Feel free to open an issue if you find any bug.
|
||||||
|
|
||||||
|
## build
|
||||||
|
1. make sure you have the latest version of [LSPosed](https://github.com/LSPosed/LSPosed)
|
||||||
|
2. clone this repo using ``git clone``
|
||||||
|
3. run ``./gradlew assembleDebug``
|
||||||
|
4. install the apk using adb ``adb install -r app/build/outputs/apk/debug/app-debug.apk``
|
||||||
|
|
||||||
|
## features
|
||||||
|
- media downloader (+ overlay merging)
|
||||||
|
- message auto save
|
||||||
|
- message in notifications
|
||||||
|
- message logger
|
||||||
|
- snapchat plus features
|
||||||
|
- anonymous story viewing
|
||||||
|
- stealth mode
|
||||||
|
- screenshot detection bypass
|
||||||
|
- conversation preview
|
||||||
|
- prevent status notifications
|
||||||
|
- UI tweaks (remove call button, record button, ...)
|
||||||
|
- ad blocker
|
||||||
|
|
||||||
|
## todo
|
||||||
|
- [] localization
|
||||||
|
- [] ui improvements
|
||||||
|
- [] snap splitting
|
16
app/.gitignore
vendored
Normal file
16
app/.gitignore
vendored
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
*.iml
|
||||||
|
.gradle
|
||||||
|
/local.properties
|
||||||
|
/.idea/caches
|
||||||
|
/.idea/libraries
|
||||||
|
/.idea/modules.xml
|
||||||
|
/.idea/workspace.xml
|
||||||
|
/.idea/navEditor.xml
|
||||||
|
/.idea/assetWizardSettings.xml
|
||||||
|
/.idea/
|
||||||
|
.DS_Store
|
||||||
|
/build
|
||||||
|
/captures
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx
|
||||||
|
local.properties
|
74
app/build.gradle
Normal file
74
app/build.gradle
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
plugins {
|
||||||
|
id 'com.android.application'
|
||||||
|
id 'org.jetbrains.kotlin.android'
|
||||||
|
}
|
||||||
|
|
||||||
|
def appVersionName = "0.0.1"
|
||||||
|
def appVersionCode = 1
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileSdk 32
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "me.rhunk.snapenhance"
|
||||||
|
minSdk 29
|
||||||
|
targetSdk 32
|
||||||
|
versionCode appVersionCode
|
||||||
|
versionName appVersionName
|
||||||
|
multiDexEnabled true
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled true
|
||||||
|
shrinkResources true
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_1_8
|
||||||
|
targetCompatibility JavaVersion.VERSION_1_8
|
||||||
|
}
|
||||||
|
|
||||||
|
//keep arm64-v8a native libs
|
||||||
|
packagingOptions {
|
||||||
|
exclude "META-INF/**"
|
||||||
|
exclude 'lib/x86/**'
|
||||||
|
exclude 'lib/x86_64/**'
|
||||||
|
exclude 'lib/armeabi-v7a/**'
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = '1.8'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEvaluate {
|
||||||
|
//auto install for debug purpose
|
||||||
|
getTasks().getByPath(":app:assembleDebug").doLast {
|
||||||
|
try {
|
||||||
|
println "Killing Snapchat"
|
||||||
|
exec {
|
||||||
|
commandLine "adb", "shell", "am", "force-stop", "com.snapchat.android"
|
||||||
|
}
|
||||||
|
println "Installing debug build"
|
||||||
|
exec() {
|
||||||
|
commandLine "adb", "install", "-r", "-d", "${buildDir}/outputs/apk/debug/app-debug.apk"
|
||||||
|
}
|
||||||
|
println "Starting Snapchat"
|
||||||
|
exec {
|
||||||
|
commandLine "adb", "shell", "am", "start", "com.snapchat.android"
|
||||||
|
}
|
||||||
|
} catch (Throwable t) {
|
||||||
|
println "Failed to install debug build"
|
||||||
|
t.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1'
|
||||||
|
compileOnly files('libs/LSPosed-api-1.0-SNAPSHOT.jar')
|
||||||
|
implementation 'com.google.code.gson:gson:2.10.1'
|
||||||
|
implementation 'com.arthenica:ffmpeg-kit-min-gpl:5.1'
|
||||||
|
}
|
BIN
app/libs/LSPosed-api-1.0-SNAPSHOT-javadoc.jar
Normal file
BIN
app/libs/LSPosed-api-1.0-SNAPSHOT-javadoc.jar
Normal file
Binary file not shown.
BIN
app/libs/LSPosed-api-1.0-SNAPSHOT-sources.jar
Normal file
BIN
app/libs/LSPosed-api-1.0-SNAPSHOT-sources.jar
Normal file
Binary file not shown.
BIN
app/libs/LSPosed-api-1.0-SNAPSHOT.jar
Normal file
BIN
app/libs/LSPosed-api-1.0-SNAPSHOT.jar
Normal file
Binary file not shown.
21
app/proguard-rules.pro
vendored
Normal file
21
app/proguard-rules.pro
vendored
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# If your project uses WebView with JS, uncomment the following
|
||||||
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
|
# class:
|
||||||
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
|
# public *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Uncomment this to preserve the line number information for
|
||||||
|
# debugging stack traces.
|
||||||
|
#-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# If you keep the line number information, uncomment this to
|
||||||
|
# hide the original source file name.
|
||||||
|
#-renamesourcefileattribute SourceFile
|
47
app/src/main/AndroidManifest.xml
Normal file
47
app/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
package="me.rhunk.snapenhance">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" />
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
|
tools:ignore="ScopedStorage" />
|
||||||
|
<application
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
tools:targetApi="31">
|
||||||
|
<meta-data
|
||||||
|
android:name="xposedmodule"
|
||||||
|
android:value="true" />
|
||||||
|
<meta-data
|
||||||
|
android:name="xposeddescription"
|
||||||
|
android:value="Enhanced Snapchat" />
|
||||||
|
<meta-data
|
||||||
|
android:name="xposedminversion"
|
||||||
|
android:value="53" />
|
||||||
|
<meta-data
|
||||||
|
android:name="xposedscope"
|
||||||
|
android:resource="@array/sc_scope" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".bridge.service.BridgeService"
|
||||||
|
android:exported="true">
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:theme="@android:style/Theme.NoDisplay"
|
||||||
|
android:name=".bridge.service.MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:excludeFromRecents="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
1
app/src/main/assets/xposed_init
Normal file
1
app/src/main/assets/xposed_init
Normal file
@ -0,0 +1 @@
|
|||||||
|
me.rhunk.snapenhance.XposedLoader
|
14
app/src/main/java/me/rhunk/snapenhance/XposedLoader.java
Normal file
14
app/src/main/java/me/rhunk/snapenhance/XposedLoader.java
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
package me.rhunk.snapenhance;
|
||||||
|
|
||||||
|
import de.robv.android.xposed.IXposedHookLoadPackage;
|
||||||
|
import de.robv.android.xposed.XposedBridge;
|
||||||
|
import de.robv.android.xposed.XposedHelpers;
|
||||||
|
import de.robv.android.xposed.callbacks.XC_LoadPackage;
|
||||||
|
|
||||||
|
public class XposedLoader implements IXposedHookLoadPackage {
|
||||||
|
@Override
|
||||||
|
public void handleLoadPackage(XC_LoadPackage.LoadPackageParam packageParam) throws Throwable {
|
||||||
|
if (!packageParam.packageName.equals(Constants.SNAPCHAT_PACKAGE_NAME)) return;
|
||||||
|
new SnapEnhance();
|
||||||
|
}
|
||||||
|
}
|
21
app/src/main/kotlin/me/rhunk/snapenhance/Constants.kt
Normal file
21
app/src/main/kotlin/me/rhunk/snapenhance/Constants.kt
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
package me.rhunk.snapenhance
|
||||||
|
|
||||||
|
object Constants {
|
||||||
|
const val TAG = "SnapEnhance"
|
||||||
|
const val SNAPCHAT_PACKAGE_NAME = "com.snapchat.android"
|
||||||
|
|
||||||
|
const val VIEW_INJECTED_CODE = 0x7FFFFF02
|
||||||
|
const val VIEW_DRAWER = 0x7FFFFF03
|
||||||
|
|
||||||
|
val ARROYO_NOTE_ENCRYPTION_PROTO_PATH = intArrayOf(4, 4, 6, 1, 1)
|
||||||
|
val ARROYO_SNAP_ENCRYPTION_PROTO_PATH = intArrayOf(4, 4, 11, 5, 1, 1)
|
||||||
|
val MESSAGE_SNAP_ENCRYPTION_PROTO_PATH = intArrayOf(11, 5, 1, 1)
|
||||||
|
val ARROYO_EXTERNAL_MEDIA_ENCRYPTION_PROTO_PATH = intArrayOf(4, 4, 3, 3, 5, 1, 1)
|
||||||
|
val ARROYO_STRING_CHAT_MESSAGE_PROTO = intArrayOf(4, 4, 2, 1)
|
||||||
|
val ARROYO_URL_KEY_PROTO_PATH = intArrayOf(4, 5, 1, 3, 2, 2)
|
||||||
|
|
||||||
|
const val ARROYO_ENCRYPTION_PROTO_INDEX = 19
|
||||||
|
const val ARROYO_ENCRYPTION_PROTO_INDEX_V2 = 4
|
||||||
|
|
||||||
|
const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
|
||||||
|
}
|
42
app/src/main/kotlin/me/rhunk/snapenhance/Logger.kt
Normal file
42
app/src/main/kotlin/me/rhunk/snapenhance/Logger.kt
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
package me.rhunk.snapenhance
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import de.robv.android.xposed.XposedBridge
|
||||||
|
|
||||||
|
object Logger {
|
||||||
|
private const val TAG = "SnapEnhance"
|
||||||
|
|
||||||
|
fun log(message: Any?) {
|
||||||
|
Log.i(TAG, message.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun debug(message: Any?) {
|
||||||
|
if (!BuildConfig.DEBUG) return
|
||||||
|
Log.d(TAG, message.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun error(throwable: Throwable) {
|
||||||
|
Log.e(TAG, "",throwable)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun error(message: Any?) {
|
||||||
|
Log.e(TAG, message.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun error(message: Any?, throwable: Throwable) {
|
||||||
|
Log.e(TAG, message.toString(), throwable)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun xposedLog(message: Any?) {
|
||||||
|
XposedBridge.log(message.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun xposedLog(message: Any?, throwable: Throwable?) {
|
||||||
|
XposedBridge.log(message.toString())
|
||||||
|
XposedBridge.log(throwable)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun xposedLog(throwable: Throwable) {
|
||||||
|
XposedBridge.log(throwable)
|
||||||
|
}
|
||||||
|
}
|
96
app/src/main/kotlin/me/rhunk/snapenhance/ModContext.kt
Normal file
96
app/src/main/kotlin/me/rhunk/snapenhance/ModContext.kt
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
package me.rhunk.snapenhance
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.res.Resources
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.os.Process
|
||||||
|
import android.widget.Toast
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.GsonBuilder
|
||||||
|
import me.rhunk.snapenhance.bridge.client.BridgeClient
|
||||||
|
import me.rhunk.snapenhance.database.DatabaseAccess
|
||||||
|
import me.rhunk.snapenhance.features.Feature
|
||||||
|
import me.rhunk.snapenhance.manager.impl.ConfigManager
|
||||||
|
import me.rhunk.snapenhance.manager.impl.FeatureManager
|
||||||
|
import me.rhunk.snapenhance.manager.impl.MappingManager
|
||||||
|
import me.rhunk.snapenhance.manager.impl.TranslationManager
|
||||||
|
import me.rhunk.snapenhance.util.download.DownloadServer
|
||||||
|
import java.util.concurrent.ExecutorService
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import kotlin.reflect.KClass
|
||||||
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
|
class ModContext {
|
||||||
|
private val executorService: ExecutorService = Executors.newCachedThreadPool()
|
||||||
|
|
||||||
|
lateinit var androidContext: Context
|
||||||
|
var mainActivity: Activity? = null
|
||||||
|
|
||||||
|
val gson: Gson = GsonBuilder().create()
|
||||||
|
|
||||||
|
val bridgeClient = BridgeClient(this)
|
||||||
|
val translation = TranslationManager(this)
|
||||||
|
val features = FeatureManager(this)
|
||||||
|
val mappings = MappingManager(this)
|
||||||
|
val config = ConfigManager(this)
|
||||||
|
val database = DatabaseAccess(this)
|
||||||
|
val downloadServer = DownloadServer(this)
|
||||||
|
val classCache get() = SnapEnhance.classCache
|
||||||
|
val resources: Resources get() = androidContext.resources
|
||||||
|
|
||||||
|
fun <T : Feature> feature(featureClass: KClass<T>): T {
|
||||||
|
return features.get(featureClass)!!
|
||||||
|
}
|
||||||
|
|
||||||
|
fun runOnUiThread(runnable: () -> Unit) {
|
||||||
|
Handler(Looper.getMainLooper()).post {
|
||||||
|
runCatching(runnable).onFailure {
|
||||||
|
Logger.xposedLog("UI thread runnable failed", it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun executeAsync(runnable: () -> Unit) {
|
||||||
|
executorService.submit {
|
||||||
|
runCatching {
|
||||||
|
runnable()
|
||||||
|
}.onFailure {
|
||||||
|
Logger.xposedLog("Async task failed", it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shortToast(message: Any) {
|
||||||
|
runOnUiThread {
|
||||||
|
Toast.makeText(androidContext, message.toString(), Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun longToast(message: Any) {
|
||||||
|
runOnUiThread {
|
||||||
|
Toast.makeText(androidContext, message.toString(), Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun restartApp() {
|
||||||
|
androidContext.packageManager.getLaunchIntentForPackage(
|
||||||
|
Constants.SNAPCHAT_PACKAGE_NAME
|
||||||
|
)?.let {
|
||||||
|
val intent = Intent.makeRestartActivityTask(it.component)
|
||||||
|
androidContext.startActivity(intent)
|
||||||
|
Runtime.getRuntime().exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun softRestartApp() {
|
||||||
|
exitProcess(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun forceCloseApp() {
|
||||||
|
Process.killProcess(Process.myPid())
|
||||||
|
exitProcess(1)
|
||||||
|
}
|
||||||
|
}
|
65
app/src/main/kotlin/me/rhunk/snapenhance/SnapEnhance.kt
Normal file
65
app/src/main/kotlin/me/rhunk/snapenhance/SnapEnhance.kt
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
package me.rhunk.snapenhance
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.Context
|
||||||
|
import me.rhunk.snapenhance.data.SnapClassCache
|
||||||
|
import me.rhunk.snapenhance.hook.HookStage
|
||||||
|
import me.rhunk.snapenhance.hook.Hooker
|
||||||
|
|
||||||
|
class SnapEnhance {
|
||||||
|
companion object {
|
||||||
|
lateinit var classLoader: ClassLoader
|
||||||
|
val classCache: SnapClassCache by lazy {
|
||||||
|
SnapClassCache(classLoader)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private val appContext = ModContext()
|
||||||
|
|
||||||
|
init {
|
||||||
|
Hooker.hook(Application::class.java, "attach", HookStage.BEFORE) { param ->
|
||||||
|
appContext.androidContext = param.arg<Context>(0).also {
|
||||||
|
classLoader = it.classLoader
|
||||||
|
}
|
||||||
|
|
||||||
|
appContext.bridgeClient.start { bridgeResult ->
|
||||||
|
if (!bridgeResult) {
|
||||||
|
Logger.xposedLog("Cannot connect to bridge service")
|
||||||
|
appContext.restartApp()
|
||||||
|
return@start
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
init()
|
||||||
|
}.onFailure {
|
||||||
|
Logger.xposedLog("Failed to initialize", it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Hooker.hook(Activity::class.java, "onCreate", HookStage.AFTER) {
|
||||||
|
val activity = it.thisObject() as Activity
|
||||||
|
if (!activity.packageName.equals(Constants.SNAPCHAT_PACKAGE_NAME)) return@hook
|
||||||
|
val isMainActivityNotNull = appContext.mainActivity != null
|
||||||
|
appContext.mainActivity = activity
|
||||||
|
if (isMainActivityNotNull) return@hook
|
||||||
|
onActivityCreate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun init() {
|
||||||
|
val time = System.currentTimeMillis()
|
||||||
|
with(appContext) {
|
||||||
|
translation.init()
|
||||||
|
config.init()
|
||||||
|
mappings.init()
|
||||||
|
features.init()
|
||||||
|
}
|
||||||
|
Logger.debug("initialized in ${System.currentTimeMillis() - time} ms")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onActivityCreate() {
|
||||||
|
with(appContext) {
|
||||||
|
features.onActivityCreate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,238 @@
|
|||||||
|
package me.rhunk.snapenhance.bridge.client
|
||||||
|
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.ServiceConnection
|
||||||
|
import android.os.*
|
||||||
|
import me.rhunk.snapenhance.BuildConfig
|
||||||
|
import me.rhunk.snapenhance.Logger.log
|
||||||
|
import me.rhunk.snapenhance.Logger.xposedLog
|
||||||
|
import me.rhunk.snapenhance.ModContext
|
||||||
|
import me.rhunk.snapenhance.bridge.common.BridgeMessage
|
||||||
|
import me.rhunk.snapenhance.bridge.common.BridgeMessageType
|
||||||
|
import me.rhunk.snapenhance.bridge.common.impl.*
|
||||||
|
import me.rhunk.snapenhance.bridge.service.BridgeService
|
||||||
|
import java.util.concurrent.CompletableFuture
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import kotlin.reflect.KClass
|
||||||
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
|
|
||||||
|
class BridgeClient(
|
||||||
|
private val context: ModContext
|
||||||
|
) : ServiceConnection {
|
||||||
|
private val handlerThread = HandlerThread("BridgeClient")
|
||||||
|
|
||||||
|
private lateinit var messenger: Messenger
|
||||||
|
private lateinit var future: CompletableFuture<Boolean>
|
||||||
|
|
||||||
|
fun start(callback: (Boolean) -> Unit = {}) {
|
||||||
|
this.future = CompletableFuture()
|
||||||
|
this.handlerThread.start()
|
||||||
|
|
||||||
|
with(context.androidContext) {
|
||||||
|
val intent = Intent()
|
||||||
|
.setClassName(BuildConfig.APPLICATION_ID, BridgeService::class.java.name)
|
||||||
|
bindService(
|
||||||
|
intent,
|
||||||
|
Context.BIND_AUTO_CREATE,
|
||||||
|
Executors.newSingleThreadExecutor(),
|
||||||
|
this@BridgeClient
|
||||||
|
)
|
||||||
|
}
|
||||||
|
callback(future.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleResponseMessage(
|
||||||
|
msg: Message,
|
||||||
|
future: CompletableFuture<BridgeMessage>
|
||||||
|
) {
|
||||||
|
val message: BridgeMessage = when (BridgeMessageType.fromValue(msg.what)) {
|
||||||
|
BridgeMessageType.FILE_ACCESS_RESULT -> FileAccessResult()
|
||||||
|
BridgeMessageType.DOWNLOAD_CONTENT_RESULT -> DownloadContentResult()
|
||||||
|
BridgeMessageType.MESSAGE_LOGGER_RESULT -> MessageLoggerResult()
|
||||||
|
else -> {
|
||||||
|
log("Unknown message type: ${msg.what}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} ?: return
|
||||||
|
|
||||||
|
with(message) {
|
||||||
|
read(msg.data)
|
||||||
|
future.complete(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST", "UNUSED_PARAMETER")
|
||||||
|
private fun <T : BridgeMessage> sendMessage(
|
||||||
|
messageType: BridgeMessageType,
|
||||||
|
message: BridgeMessage,
|
||||||
|
resultType: KClass<T>? = null
|
||||||
|
): T {
|
||||||
|
val future = CompletableFuture<BridgeMessage>()
|
||||||
|
|
||||||
|
val replyMessenger = Messenger(object : Handler(handlerThread.looper) {
|
||||||
|
override fun handleMessage(msg: Message) {
|
||||||
|
handleResponseMessage(msg, future)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
with(Message.obtain()) {
|
||||||
|
what = messageType.value
|
||||||
|
replyTo = replyMessenger
|
||||||
|
data = Bundle()
|
||||||
|
message.write(data)
|
||||||
|
messenger.send(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return future.get() as T
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a file if it doesn't exist, and read it
|
||||||
|
*
|
||||||
|
* @param fileType the type of file to create and read
|
||||||
|
* @param defaultContent the default content to write to the file if it doesn't exist
|
||||||
|
* @return the content of the file
|
||||||
|
*/
|
||||||
|
fun createAndReadFile(
|
||||||
|
fileType: FileAccessRequest.FileType,
|
||||||
|
defaultContent: ByteArray
|
||||||
|
): ByteArray {
|
||||||
|
sendMessage(
|
||||||
|
BridgeMessageType.FILE_ACCESS_REQUEST,
|
||||||
|
FileAccessRequest(FileAccessRequest.FileAccessAction.EXISTS, fileType, null),
|
||||||
|
FileAccessResult::class
|
||||||
|
).run {
|
||||||
|
if (state!!) {
|
||||||
|
return readFile(fileType)
|
||||||
|
}
|
||||||
|
writeFile(fileType, defaultContent)
|
||||||
|
return defaultContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a file
|
||||||
|
*
|
||||||
|
* @param fileType the type of file to read
|
||||||
|
* @return the content of the file
|
||||||
|
*/
|
||||||
|
fun readFile(fileType: FileAccessRequest.FileType): ByteArray {
|
||||||
|
sendMessage(
|
||||||
|
BridgeMessageType.FILE_ACCESS_REQUEST,
|
||||||
|
FileAccessRequest(FileAccessRequest.FileAccessAction.READ, fileType, null),
|
||||||
|
FileAccessResult::class
|
||||||
|
).run {
|
||||||
|
return content!!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a file
|
||||||
|
*
|
||||||
|
* @param fileType the type of file to write
|
||||||
|
* @param content the content to write to the file
|
||||||
|
* @return true if the file was written successfully
|
||||||
|
*/
|
||||||
|
fun writeFile(
|
||||||
|
fileType: FileAccessRequest.FileType,
|
||||||
|
content: ByteArray?
|
||||||
|
): Boolean {
|
||||||
|
sendMessage(
|
||||||
|
BridgeMessageType.FILE_ACCESS_REQUEST,
|
||||||
|
FileAccessRequest(FileAccessRequest.FileAccessAction.WRITE, fileType, content),
|
||||||
|
FileAccessResult::class
|
||||||
|
).run {
|
||||||
|
return state!!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a file
|
||||||
|
*
|
||||||
|
* @param fileType the type of file to delete
|
||||||
|
* @return true if the file was deleted successfully
|
||||||
|
*/
|
||||||
|
fun deleteFile(fileType: FileAccessRequest.FileType): Boolean {
|
||||||
|
sendMessage(
|
||||||
|
BridgeMessageType.FILE_ACCESS_REQUEST,
|
||||||
|
FileAccessRequest(FileAccessRequest.FileAccessAction.DELETE, fileType, null),
|
||||||
|
FileAccessResult::class
|
||||||
|
).run {
|
||||||
|
return state!!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a file exists
|
||||||
|
*
|
||||||
|
* @param fileType the type of file to check
|
||||||
|
* @return true if the file exists
|
||||||
|
*/
|
||||||
|
|
||||||
|
fun isFileExists(fileType: FileAccessRequest.FileType): Boolean {
|
||||||
|
sendMessage(
|
||||||
|
BridgeMessageType.FILE_ACCESS_REQUEST,
|
||||||
|
FileAccessRequest(FileAccessRequest.FileAccessAction.EXISTS, fileType, null),
|
||||||
|
FileAccessResult::class
|
||||||
|
).run {
|
||||||
|
return state!!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download content from a URL and save it to a file
|
||||||
|
*
|
||||||
|
* @param url the URL to download content from
|
||||||
|
* @param path the path to save the content to
|
||||||
|
* @return true if the content was downloaded successfully
|
||||||
|
*/
|
||||||
|
fun downloadContent(url: String, path: String): Boolean {
|
||||||
|
sendMessage(
|
||||||
|
BridgeMessageType.DOWNLOAD_CONTENT_REQUEST,
|
||||||
|
DownloadContentRequest(url, path),
|
||||||
|
DownloadContentResult::class
|
||||||
|
).run {
|
||||||
|
return state!!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMessageLoggerMessage(id: Long): ByteArray? {
|
||||||
|
sendMessage(
|
||||||
|
BridgeMessageType.MESSAGE_LOGGER_REQUEST,
|
||||||
|
MessageLoggerRequest(MessageLoggerRequest.Action.GET, id),
|
||||||
|
MessageLoggerResult::class
|
||||||
|
).run {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addMessageLoggerMessage(id: Long, message: ByteArray) {
|
||||||
|
sendMessage(
|
||||||
|
BridgeMessageType.MESSAGE_LOGGER_REQUEST,
|
||||||
|
MessageLoggerRequest(MessageLoggerRequest.Action.ADD, id, message),
|
||||||
|
MessageLoggerResult::class
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceConnected(name: ComponentName, service: IBinder) {
|
||||||
|
messenger = Messenger(service)
|
||||||
|
future.complete(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNullBinding(name: ComponentName) {
|
||||||
|
xposedLog("failed to connect to bridge service")
|
||||||
|
future.complete(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceDisconnected(name: ComponentName) {
|
||||||
|
context.longToast("Bridge service disconnected")
|
||||||
|
Thread.sleep(1000)
|
||||||
|
exitProcess(0)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,16 @@
|
|||||||
|
package me.rhunk.snapenhance.bridge.common
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Message
|
||||||
|
|
||||||
|
abstract class BridgeMessage {
|
||||||
|
abstract fun write(bundle: Bundle)
|
||||||
|
abstract fun read(bundle: Bundle)
|
||||||
|
|
||||||
|
fun toMessage(what: Int): Message {
|
||||||
|
val message = Message.obtain(null, what)
|
||||||
|
message.data = Bundle()
|
||||||
|
write(message.data)
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,22 @@
|
|||||||
|
package me.rhunk.snapenhance.bridge.common
|
||||||
|
|
||||||
|
|
||||||
|
enum class BridgeMessageType(
|
||||||
|
val value: Int = 0
|
||||||
|
) {
|
||||||
|
UNKNOWN(-1),
|
||||||
|
FILE_ACCESS_REQUEST(0),
|
||||||
|
FILE_ACCESS_RESULT(1),
|
||||||
|
DOWNLOAD_CONTENT_REQUEST(2),
|
||||||
|
DOWNLOAD_CONTENT_RESULT(3),
|
||||||
|
LOCALE_REQUEST(4),
|
||||||
|
LOCALE_RESULT(5),
|
||||||
|
MESSAGE_LOGGER_REQUEST(6),
|
||||||
|
MESSAGE_LOGGER_RESULT(7);
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromValue(value: Int): BridgeMessageType {
|
||||||
|
return values().firstOrNull { it.value == value } ?: UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,20 @@
|
|||||||
|
package me.rhunk.snapenhance.bridge.common.impl
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import me.rhunk.snapenhance.bridge.common.BridgeMessage
|
||||||
|
|
||||||
|
class DownloadContentRequest(
|
||||||
|
var url: String? = null,
|
||||||
|
var path: String? = null
|
||||||
|
) : BridgeMessage() {
|
||||||
|
|
||||||
|
override fun write(bundle: Bundle) {
|
||||||
|
bundle.putString("url", url)
|
||||||
|
bundle.putString("path", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun read(bundle: Bundle) {
|
||||||
|
url = bundle.getString("url")
|
||||||
|
path = bundle.getString("path")
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,17 @@
|
|||||||
|
package me.rhunk.snapenhance.bridge.common.impl
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import me.rhunk.snapenhance.bridge.common.BridgeMessage
|
||||||
|
|
||||||
|
class DownloadContentResult(
|
||||||
|
var state: Boolean? = null
|
||||||
|
) : BridgeMessage() {
|
||||||
|
|
||||||
|
override fun write(bundle: Bundle) {
|
||||||
|
bundle.putBoolean("state", state!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun read(bundle: Bundle) {
|
||||||
|
state = bundle.getBoolean("state")
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,43 @@
|
|||||||
|
package me.rhunk.snapenhance.bridge.common.impl
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import me.rhunk.snapenhance.bridge.common.BridgeMessage
|
||||||
|
|
||||||
|
class FileAccessRequest(
|
||||||
|
var action: FileAccessAction? = null,
|
||||||
|
var fileType: FileType? = null,
|
||||||
|
var content: ByteArray? = null
|
||||||
|
) : BridgeMessage() {
|
||||||
|
|
||||||
|
override fun write(bundle: Bundle) {
|
||||||
|
bundle.putInt("action", action!!.value)
|
||||||
|
bundle.putInt("fileType", fileType!!.value)
|
||||||
|
bundle.putByteArray("content", content)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun read(bundle: Bundle) {
|
||||||
|
action = FileAccessAction.fromValue(bundle.getInt("action"))
|
||||||
|
fileType = FileType.fromValue(bundle.getInt("fileType"))
|
||||||
|
content = bundle.getByteArray("content")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class FileType(val value: Int) {
|
||||||
|
CONFIG(0), MAPPINGS(1), STEALTH(2);
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromValue(value: Int): FileType? {
|
||||||
|
return values().firstOrNull { it.value == value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class FileAccessAction(val value: Int) {
|
||||||
|
READ(0), WRITE(1), DELETE(2), EXISTS(3);
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromValue(value: Int): FileAccessAction? {
|
||||||
|
return values().firstOrNull { it.value == value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,20 @@
|
|||||||
|
package me.rhunk.snapenhance.bridge.common.impl
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import me.rhunk.snapenhance.bridge.common.BridgeMessage
|
||||||
|
|
||||||
|
class FileAccessResult(
|
||||||
|
var state: Boolean? = null,
|
||||||
|
var content: ByteArray? = null
|
||||||
|
) : BridgeMessage() {
|
||||||
|
|
||||||
|
override fun write(bundle: Bundle) {
|
||||||
|
bundle.putBoolean("state", state!!)
|
||||||
|
bundle.putByteArray("content", content)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun read(bundle: Bundle) {
|
||||||
|
state = bundle.getBoolean("state")
|
||||||
|
content = bundle.getByteArray("content")
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,17 @@
|
|||||||
|
package me.rhunk.snapenhance.bridge.common.impl
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import me.rhunk.snapenhance.bridge.common.BridgeMessage
|
||||||
|
|
||||||
|
class LocaleRequest(
|
||||||
|
var locale: String? = null
|
||||||
|
) : BridgeMessage() {
|
||||||
|
|
||||||
|
override fun write(bundle: Bundle) {
|
||||||
|
bundle.putString("locale", locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun read(bundle: Bundle) {
|
||||||
|
locale = bundle.getString("locale")
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,19 @@
|
|||||||
|
package me.rhunk.snapenhance.bridge.common.impl
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import me.rhunk.snapenhance.bridge.common.BridgeMessage
|
||||||
|
|
||||||
|
class LocaleResult(
|
||||||
|
var locale: String? = null,
|
||||||
|
var content: ByteArray? = null
|
||||||
|
) : BridgeMessage(){
|
||||||
|
override fun write(bundle: Bundle) {
|
||||||
|
bundle.putString("locale", locale)
|
||||||
|
bundle.putByteArray("content", content)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun read(bundle: Bundle) {
|
||||||
|
locale = bundle.getString("locale")
|
||||||
|
content = bundle.getByteArray("content")
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,29 @@
|
|||||||
|
package me.rhunk.snapenhance.bridge.common.impl
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import me.rhunk.snapenhance.bridge.common.BridgeMessage
|
||||||
|
|
||||||
|
class MessageLoggerRequest(
|
||||||
|
var action: Action? = null,
|
||||||
|
var messageId: Long? = null,
|
||||||
|
var message: ByteArray? = null
|
||||||
|
) : BridgeMessage(){
|
||||||
|
|
||||||
|
override fun write(bundle: Bundle) {
|
||||||
|
bundle.putString("action", action!!.name)
|
||||||
|
bundle.putLong("messageId", messageId!!)
|
||||||
|
bundle.putByteArray("message", message)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun read(bundle: Bundle) {
|
||||||
|
action = Action.valueOf(bundle.getString("action")!!)
|
||||||
|
messageId = bundle.getLong("messageId")
|
||||||
|
message = bundle.getByteArray("message")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class Action {
|
||||||
|
ADD,
|
||||||
|
GET,
|
||||||
|
CLEAR
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,20 @@
|
|||||||
|
package me.rhunk.snapenhance.bridge.common.impl
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import me.rhunk.snapenhance.bridge.common.BridgeMessage
|
||||||
|
|
||||||
|
class MessageLoggerResult(
|
||||||
|
var state: Boolean? = null,
|
||||||
|
var message: ByteArray? = null
|
||||||
|
) : BridgeMessage() {
|
||||||
|
|
||||||
|
override fun write(bundle: Bundle) {
|
||||||
|
bundle.putBoolean("state", state!!)
|
||||||
|
bundle.putByteArray("message", message)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun read(bundle: Bundle) {
|
||||||
|
state = bundle.getBoolean("state")
|
||||||
|
message = bundle.getByteArray("message")
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,180 @@
|
|||||||
|
package me.rhunk.snapenhance.bridge.service
|
||||||
|
|
||||||
|
import android.app.DownloadManager
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.*
|
||||||
|
import android.database.sqlite.SQLiteDatabase
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.*
|
||||||
|
import me.rhunk.snapenhance.Logger
|
||||||
|
import me.rhunk.snapenhance.bridge.common.BridgeMessageType
|
||||||
|
import me.rhunk.snapenhance.bridge.common.impl.*
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class BridgeService : Service() {
|
||||||
|
companion object {
|
||||||
|
const val CONFIG_FILE = "config.json"
|
||||||
|
const val MAPPINGS_FILE = "mappings.json"
|
||||||
|
const val STEALTH_FILE = "stealth.txt"
|
||||||
|
const val MESSAGE_LOGGER_DATABASE = "message_logger"
|
||||||
|
}
|
||||||
|
|
||||||
|
lateinit var messageLoggerDatabase: SQLiteDatabase
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent): IBinder {
|
||||||
|
with(openOrCreateDatabase(MESSAGE_LOGGER_DATABASE, Context.MODE_PRIVATE, null)) {
|
||||||
|
messageLoggerDatabase = this
|
||||||
|
execSQL("CREATE TABLE IF NOT EXISTS messages (message_id INTEGER PRIMARY KEY, serialized_message BLOB)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return Messenger(object : Handler(Looper.getMainLooper()) {
|
||||||
|
override fun handleMessage(msg: Message) {
|
||||||
|
runCatching {
|
||||||
|
this@BridgeService.handleMessage(msg)
|
||||||
|
}.onFailure {
|
||||||
|
Logger.error("Failed to handle message", it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).binder
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private fun handleMessage(msg: Message) {
|
||||||
|
val replyMessenger = msg.replyTo
|
||||||
|
when (BridgeMessageType.fromValue(msg.what)) {
|
||||||
|
BridgeMessageType.FILE_ACCESS_REQUEST -> {
|
||||||
|
with(FileAccessRequest()) {
|
||||||
|
read(msg.data)
|
||||||
|
handleFileAccess(this) { message ->
|
||||||
|
replyMessenger.send(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BridgeMessageType.DOWNLOAD_CONTENT_REQUEST -> {
|
||||||
|
with(DownloadContentRequest()) {
|
||||||
|
read(msg.data)
|
||||||
|
handleDownloadContent(this) { message ->
|
||||||
|
replyMessenger.send(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BridgeMessageType.LOCALE_REQUEST -> {
|
||||||
|
with(LocaleRequest()) {
|
||||||
|
read(msg.data)
|
||||||
|
handleLocaleRequest(this) { message ->
|
||||||
|
replyMessenger.send(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BridgeMessageType.MESSAGE_LOGGER_REQUEST -> {
|
||||||
|
with(MessageLoggerRequest()) {
|
||||||
|
read(msg.data)
|
||||||
|
handleMessageLoggerRequest(this) { message ->
|
||||||
|
replyMessenger.send(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> Logger.error("Unknown message type: " + msg.what)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleMessageLoggerRequest(msg: MessageLoggerRequest, reply: (Message) -> Unit) {
|
||||||
|
when (msg.action) {
|
||||||
|
MessageLoggerRequest.Action.ADD -> {
|
||||||
|
messageLoggerDatabase.insert("messages", null, ContentValues().apply {
|
||||||
|
put("message_id", msg.messageId)
|
||||||
|
put("serialized_message", msg.message)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
MessageLoggerRequest.Action.CLEAR -> {
|
||||||
|
messageLoggerDatabase.execSQL("DELETE FROM messages")
|
||||||
|
}
|
||||||
|
MessageLoggerRequest.Action.GET -> {
|
||||||
|
val messageId = msg.messageId
|
||||||
|
val cursor = messageLoggerDatabase.rawQuery("SELECT serialized_message FROM messages WHERE message_id = ?", arrayOf(messageId.toString()))
|
||||||
|
val state = cursor.moveToFirst()
|
||||||
|
val message: ByteArray? = if (state) {
|
||||||
|
cursor.getBlob(0)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
cursor.close()
|
||||||
|
reply(MessageLoggerResult(state, message).toMessage(BridgeMessageType.MESSAGE_LOGGER_RESULT.value))
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Logger.error(Exception("Unknown message logger action: ${msg.action}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reply(MessageLoggerResult(true).toMessage(BridgeMessageType.MESSAGE_LOGGER_RESULT.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleLocaleRequest(msg: LocaleRequest, reply: (Message) -> Unit) {
|
||||||
|
val locale = resources.configuration.locales[0]
|
||||||
|
Logger.log("Locale: ${locale.language}_${locale.country}")
|
||||||
|
TODO()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleDownloadContent(msg: DownloadContentRequest, reply: (Message) -> Unit) {
|
||||||
|
if (!msg.url!!.startsWith("http://127.0.0.1:")) return
|
||||||
|
|
||||||
|
val outputFile = File(msg.path!!)
|
||||||
|
outputFile.parentFile?.let {
|
||||||
|
if (!it.exists()) it.mkdirs()
|
||||||
|
}
|
||||||
|
val downloadManager = getSystemService(DOWNLOAD_SERVICE) as DownloadManager
|
||||||
|
val request = DownloadManager.Request(Uri.parse(msg.url))
|
||||||
|
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
|
||||||
|
.setAllowedOverMetered(true)
|
||||||
|
.setAllowedOverRoaming(true)
|
||||||
|
.setDestinationUri(Uri.fromFile(outputFile))
|
||||||
|
val downloadId = downloadManager.enqueue(request)
|
||||||
|
registerReceiver(object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
if (intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) != downloadId) return
|
||||||
|
unregisterReceiver(this)
|
||||||
|
reply(DownloadContentResult(true).toMessage(BridgeMessageType.DOWNLOAD_CONTENT_RESULT.value))
|
||||||
|
}
|
||||||
|
}, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleFileAccess(msg: FileAccessRequest, reply: (Message) -> Unit) {
|
||||||
|
val file = when (msg.fileType) {
|
||||||
|
FileAccessRequest.FileType.CONFIG -> CONFIG_FILE
|
||||||
|
FileAccessRequest.FileType.MAPPINGS -> MAPPINGS_FILE
|
||||||
|
FileAccessRequest.FileType.STEALTH -> STEALTH_FILE
|
||||||
|
else -> throw Exception("Unknown file type: " + msg.fileType)
|
||||||
|
}.let { File(filesDir, it) }
|
||||||
|
|
||||||
|
val result: FileAccessResult = when (msg.action) {
|
||||||
|
FileAccessRequest.FileAccessAction.READ -> {
|
||||||
|
if (!file.exists()) {
|
||||||
|
FileAccessResult(false, null)
|
||||||
|
} else {
|
||||||
|
FileAccessResult(true, file.readBytes())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FileAccessRequest.FileAccessAction.WRITE -> {
|
||||||
|
if (!file.exists()) {
|
||||||
|
file.createNewFile()
|
||||||
|
}
|
||||||
|
file.writeBytes(msg.content!!)
|
||||||
|
FileAccessResult(true, null)
|
||||||
|
}
|
||||||
|
FileAccessRequest.FileAccessAction.DELETE -> {
|
||||||
|
if (!file.exists()) {
|
||||||
|
FileAccessResult(false, null)
|
||||||
|
} else {
|
||||||
|
file.delete()
|
||||||
|
FileAccessResult(true, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FileAccessRequest.FileAccessAction.EXISTS -> FileAccessResult(file.exists(), null)
|
||||||
|
else -> throw Exception("Unknown action: " + msg.action)
|
||||||
|
}
|
||||||
|
|
||||||
|
reply(result.toMessage(BridgeMessageType.FILE_ACCESS_RESULT.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,20 @@
|
|||||||
|
package me.rhunk.snapenhance.bridge.service
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import me.rhunk.snapenhance.Constants
|
||||||
|
|
||||||
|
class MainActivity : Activity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
if (intent.getBooleanExtra("is_from_bridge", false)) {
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val intent = packageManager.getLaunchIntentForPackage(Constants.SNAPCHAT_PACKAGE_NAME)
|
||||||
|
intent!!.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
startActivity(intent)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,58 @@
|
|||||||
|
package me.rhunk.snapenhance.config
|
||||||
|
|
||||||
|
open class ConfigAccessor(
|
||||||
|
private val configMap: MutableMap<ConfigProperty, Any?>
|
||||||
|
) {
|
||||||
|
fun bool(key: ConfigProperty): Boolean {
|
||||||
|
return get(key) as Boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
fun int(key: ConfigProperty): Int {
|
||||||
|
return get(key) as Int
|
||||||
|
}
|
||||||
|
|
||||||
|
fun string(key: ConfigProperty): String {
|
||||||
|
return get(key) as String
|
||||||
|
}
|
||||||
|
|
||||||
|
fun double(key: ConfigProperty): Double {
|
||||||
|
return get(key) as Double
|
||||||
|
}
|
||||||
|
|
||||||
|
fun float(key: ConfigProperty): Float {
|
||||||
|
return get(key) as Float
|
||||||
|
}
|
||||||
|
|
||||||
|
fun long(key: ConfigProperty): Long {
|
||||||
|
return get(key) as Long
|
||||||
|
}
|
||||||
|
|
||||||
|
fun short(key: ConfigProperty): Short {
|
||||||
|
return get(key) as Short
|
||||||
|
}
|
||||||
|
|
||||||
|
fun byte(key: ConfigProperty): Byte {
|
||||||
|
return get(key) as Byte
|
||||||
|
}
|
||||||
|
|
||||||
|
fun char(key: ConfigProperty): Char {
|
||||||
|
return get(key) as Char
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
fun <T> list(key: ConfigProperty): List<T> {
|
||||||
|
return get(key) as List<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
fun get(key: ConfigProperty): Any? {
|
||||||
|
return configMap[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun set(key: ConfigProperty, value: Any?) {
|
||||||
|
configMap[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun entries(): Set<Map.Entry<ConfigProperty, Any?>> {
|
||||||
|
return configMap.entries
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,14 @@
|
|||||||
|
package me.rhunk.snapenhance.config
|
||||||
|
|
||||||
|
enum class ConfigCategory(
|
||||||
|
val key: String
|
||||||
|
) {
|
||||||
|
GENERAL("general"),
|
||||||
|
SPY("spy"),
|
||||||
|
MEDIA_DOWNLOADER("media_download"),
|
||||||
|
PRIVACY("privacy"),
|
||||||
|
UI("ui"),
|
||||||
|
EXTRAS("extras"),
|
||||||
|
TWEAKS("tweaks"),
|
||||||
|
EXPERIMENTS("experiments");
|
||||||
|
}
|
@ -0,0 +1,181 @@
|
|||||||
|
package me.rhunk.snapenhance.config
|
||||||
|
|
||||||
|
import android.os.Environment
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
enum class ConfigProperty(
|
||||||
|
val nameKey: String,
|
||||||
|
val descriptionKey: String,
|
||||||
|
val category: ConfigCategory,
|
||||||
|
val defaultValue: Any
|
||||||
|
) {
|
||||||
|
SAVE_FOLDER(
|
||||||
|
"save_folder", "description.save_folder", ConfigCategory.GENERAL,
|
||||||
|
File(
|
||||||
|
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM).absolutePath + "/Snapchat",
|
||||||
|
"SnapEnhance"
|
||||||
|
).absolutePath
|
||||||
|
),
|
||||||
|
|
||||||
|
PREVENT_READ_RECEIPTS(
|
||||||
|
"prevent_read_receipts",
|
||||||
|
"description.prevent_read_receipts",
|
||||||
|
ConfigCategory.SPY,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
HIDE_BITMOJI_PRESENCE(
|
||||||
|
"hide_bitmoji_presence",
|
||||||
|
"description.hide_bitmoji_presence",
|
||||||
|
ConfigCategory.SPY,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
SHOW_MESSAGE_CONTENT(
|
||||||
|
"show_message_content",
|
||||||
|
"description.show_message_content",
|
||||||
|
ConfigCategory.SPY,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
MESSAGE_LOGGER("message_logger", "description.message_logger", ConfigCategory.SPY, false),
|
||||||
|
|
||||||
|
MEDIA_DOWNLOADER_FEATURE(
|
||||||
|
"media_downloader_feature",
|
||||||
|
"description.media_downloader_feature",
|
||||||
|
ConfigCategory.MEDIA_DOWNLOADER,
|
||||||
|
true
|
||||||
|
),
|
||||||
|
DOWNLOAD_STORIES(
|
||||||
|
"download_stories",
|
||||||
|
"description.download_stories",
|
||||||
|
ConfigCategory.MEDIA_DOWNLOADER,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
DOWNLOAD_PUBLIC_STORIES(
|
||||||
|
"download_public_stories",
|
||||||
|
"description.download_public_stories",
|
||||||
|
ConfigCategory.MEDIA_DOWNLOADER,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
DOWNLOAD_SPOTLIGHT(
|
||||||
|
"download_spotlight",
|
||||||
|
"description.download_spotlight",
|
||||||
|
ConfigCategory.MEDIA_DOWNLOADER,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
OVERLAY_MERGE(
|
||||||
|
"overlay_merge",
|
||||||
|
"description.overlay_merge",
|
||||||
|
ConfigCategory.MEDIA_DOWNLOADER,
|
||||||
|
true
|
||||||
|
),
|
||||||
|
DOWNLOAD_INCHAT_SNAPS(
|
||||||
|
"download_inchat_snaps",
|
||||||
|
"description.download_inchat_snaps",
|
||||||
|
ConfigCategory.MEDIA_DOWNLOADER,
|
||||||
|
true
|
||||||
|
),
|
||||||
|
|
||||||
|
DISABLE_METRICS("disable_metrics", "description.disable_metrics", ConfigCategory.PRIVACY, true),
|
||||||
|
PREVENT_SCREENSHOTS(
|
||||||
|
"prevent_screenshots",
|
||||||
|
"description.prevent_screenshots",
|
||||||
|
ConfigCategory.PRIVACY,
|
||||||
|
true
|
||||||
|
),
|
||||||
|
PREVENT_STATUS_NOTIFICATIONS(
|
||||||
|
"prevent_status_notifications",
|
||||||
|
"description.prevent_status_notifications",
|
||||||
|
ConfigCategory.PRIVACY,
|
||||||
|
true
|
||||||
|
),
|
||||||
|
ANONYMOUS_STORY_VIEW(
|
||||||
|
"anonymous_story_view",
|
||||||
|
"description.anonymous_story_view",
|
||||||
|
ConfigCategory.PRIVACY,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
HIDE_TYPING_NOTIFICATION(
|
||||||
|
"hide_typing_notification",
|
||||||
|
"description.hide_typing_notification",
|
||||||
|
ConfigCategory.PRIVACY,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
|
||||||
|
MENU_SLOT_ID("menu_slot_id", "description.menu_slot_id", ConfigCategory.UI, 1),
|
||||||
|
MESSAGE_PREVIEW_LENGTH(
|
||||||
|
"message_preview_length",
|
||||||
|
"description.message_preview_length",
|
||||||
|
ConfigCategory.UI,
|
||||||
|
20
|
||||||
|
),
|
||||||
|
|
||||||
|
AUTO_SAVE("auto_save", "description.auto_save", ConfigCategory.EXTRAS, false),
|
||||||
|
/*EXTERNAL_MEDIA_AS_SNAP(
|
||||||
|
"external_media_as_snap",
|
||||||
|
"description.external_media_as_snap",
|
||||||
|
ConfigCategory.EXTRAS,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
CONVERSATION_EXPORT(
|
||||||
|
"conversation_export",
|
||||||
|
"description.conversation_export",
|
||||||
|
ConfigCategory.EXTRAS,
|
||||||
|
false
|
||||||
|
),*/
|
||||||
|
SNAPCHAT_PLUS("snapchat_plus", "description.snapchat_plus", ConfigCategory.EXTRAS, false),
|
||||||
|
|
||||||
|
REMOVE_VOICE_RECORD_BUTTON(
|
||||||
|
"remove_voice_record_button",
|
||||||
|
"description.remove_voice_record_button",
|
||||||
|
ConfigCategory.TWEAKS,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
REMOVE_STICKERS_BUTTON(
|
||||||
|
"remove_stickers_button",
|
||||||
|
"description.remove_stickers_button",
|
||||||
|
ConfigCategory.TWEAKS,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
REMOVE_COGNAC_BUTTON(
|
||||||
|
"remove_cognac_button",
|
||||||
|
"description.remove_cognac_button",
|
||||||
|
ConfigCategory.TWEAKS,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
REMOVE_CALLBUTTONS(
|
||||||
|
"remove_callbuttons",
|
||||||
|
"description.remove_callbuttons",
|
||||||
|
ConfigCategory.TWEAKS,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
LONG_SNAP_SENDING(
|
||||||
|
"long_snap_sending",
|
||||||
|
"description.long_snap_sending",
|
||||||
|
ConfigCategory.TWEAKS,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
BLOCK_ADS("block_ads", "description.block_ads", ConfigCategory.TWEAKS, false),
|
||||||
|
STREAKEXPIRATIONINFO(
|
||||||
|
"streakexpirationinfo",
|
||||||
|
"description.streakexpirationinfo",
|
||||||
|
ConfigCategory.TWEAKS,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
NEW_MAP_UI("new_map_ui", "description.new_map_ui", ConfigCategory.TWEAKS, false),
|
||||||
|
|
||||||
|
USE_DOWNLOAD_MANAGER(
|
||||||
|
"use_download_manager",
|
||||||
|
"description.use_download_manager",
|
||||||
|
ConfigCategory.EXPERIMENTS,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromNameKey(nameKey: String): ConfigProperty? {
|
||||||
|
return values().find { it.nameKey == nameKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sortedByCategory(): List<ConfigProperty> {
|
||||||
|
return values().sortedBy { it.category.ordinal }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
50
app/src/main/kotlin/me/rhunk/snapenhance/data/FileType.kt
Normal file
50
app/src/main/kotlin/me/rhunk/snapenhance/data/FileType.kt
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
package me.rhunk.snapenhance.data
|
||||||
|
|
||||||
|
enum class FileType(
|
||||||
|
val fileExtension: String? = null,
|
||||||
|
val isVideo: Boolean = false,
|
||||||
|
val isImage: Boolean = false,
|
||||||
|
val isAudio: Boolean = false
|
||||||
|
) {
|
||||||
|
GIF("gif", false, false, false),
|
||||||
|
PNG("png", false, true, false),
|
||||||
|
MP4("mp4", true, false, false),
|
||||||
|
MP3("mp3", false, false, true),
|
||||||
|
JPG("jpg", false, true, false),
|
||||||
|
ZIP("zip", false, false, false),
|
||||||
|
WEBP("webp", false, true, false),
|
||||||
|
UNKNOWN("dat", false, false, false);
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val fileSignatures = HashMap<String, FileType>()
|
||||||
|
|
||||||
|
init {
|
||||||
|
fileSignatures["52494646"] = WEBP
|
||||||
|
fileSignatures["504b0304"] = ZIP
|
||||||
|
fileSignatures["89504e47"] = PNG
|
||||||
|
fileSignatures["00000020"] = MP4
|
||||||
|
fileSignatures["00000018"] = MP4
|
||||||
|
fileSignatures["0000001c"] = MP4
|
||||||
|
fileSignatures["ffd8ffe0"] = JPG
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fromString(string: String?): FileType {
|
||||||
|
return values().firstOrNull { it.fileExtension.equals(string, ignoreCase = true) } ?: UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bytesToHex(bytes: ByteArray): String {
|
||||||
|
val result = StringBuilder()
|
||||||
|
for (b in bytes) {
|
||||||
|
result.append(String.format("%02x", b))
|
||||||
|
}
|
||||||
|
return result.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fromByteArray(array: ByteArray): FileType {
|
||||||
|
val headerBytes = ByteArray(16)
|
||||||
|
System.arraycopy(array, 0, headerBytes, 0, 16)
|
||||||
|
val hex = bytesToHex(headerBytes)
|
||||||
|
return fileSignatures.entries.firstOrNull { hex.startsWith(it.key) }?.value ?: UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,25 @@
|
|||||||
|
package me.rhunk.snapenhance.data
|
||||||
|
|
||||||
|
class SnapClassCache (
|
||||||
|
private val classLoader: ClassLoader
|
||||||
|
) {
|
||||||
|
val snapUUID by lazy { findClass("com.snapchat.client.messaging.UUID") }
|
||||||
|
val composerLocalSubscriptionStore by lazy { findClass("com.snap.plus.lib.common.ComposerLocalSubscriptionStore") }
|
||||||
|
val snapManager by lazy { findClass("com.snapchat.client.messaging.SnapManager\$CppProxy") }
|
||||||
|
val conversationManager by lazy { findClass("com.snapchat.client.messaging.ConversationManager\$CppProxy") }
|
||||||
|
val feedManager by lazy { findClass("com.snapchat.client.messaging.FeedManager\$CppProxy") }
|
||||||
|
val presenceSession by lazy { findClass("com.snapchat.talkcorev3.PresenceSession\$CppProxy") }
|
||||||
|
val message by lazy { findClass("com.snapchat.client.messaging.Message") }
|
||||||
|
val messageUpdateEnum by lazy { findClass("com.snapchat.client.messaging.MessageUpdate") }
|
||||||
|
val bestFriendWidgetProvider by lazy { findClass("com.snap.widgets.core.BestFriendsWidgetProvider") }
|
||||||
|
val unifiedGrpcService by lazy { findClass("com.snapchat.client.grpc.UnifiedGrpcService\$CppProxy") }
|
||||||
|
val networkApi by lazy { findClass("com.snapchat.client.network_api.NetworkApi\$CppProxy") }
|
||||||
|
|
||||||
|
private fun findClass(className: String): Class<*> {
|
||||||
|
return try {
|
||||||
|
classLoader.loadClass(className)
|
||||||
|
} catch (e: ClassNotFoundException) {
|
||||||
|
throw RuntimeException("Failed to find class $className", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
41
app/src/main/kotlin/me/rhunk/snapenhance/data/SnapEnums.kt
Normal file
41
app/src/main/kotlin/me/rhunk/snapenhance/data/SnapEnums.kt
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
package me.rhunk.snapenhance.data
|
||||||
|
|
||||||
|
enum class MessageState {
|
||||||
|
PREPARING, SENDING, COMMITTED, FAILED, CANCELING
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ContentType(val id: Int) {
|
||||||
|
UNKNOWN(-1),
|
||||||
|
SNAP(0),
|
||||||
|
CHAT(1),
|
||||||
|
EXTERNAL_MEDIA(2),
|
||||||
|
SHARE(3),
|
||||||
|
NOTE(4),
|
||||||
|
STICKER(5),
|
||||||
|
STATUS(6),
|
||||||
|
LOCATION(7),
|
||||||
|
STATUS_SAVE_TO_CAMERA_ROLL(8),
|
||||||
|
STATUS_CONVERSATION_CAPTURE_SCREENSHOT(9),
|
||||||
|
STATUS_CONVERSATION_CAPTURE_RECORD(10),
|
||||||
|
STATUS_CALL_MISSED_VIDEO(11),
|
||||||
|
STATUS_CALL_MISSED_AUDIO(12),
|
||||||
|
LIVE_LOCATION_SHARE(13),
|
||||||
|
CREATIVE_TOOL_ITEM(14),
|
||||||
|
FAMILY_CENTER_INVITE(15),
|
||||||
|
FAMILY_CENTER_ACCEPT(16),
|
||||||
|
FAMILY_CENTER_LEAVE(17);
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromId(i: Int): ContentType {
|
||||||
|
return values().firstOrNull { it.id == i } ?: UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class PlayableSnapState {
|
||||||
|
NOTDOWNLOADED, DOWNLOADING, DOWNLOADFAILED, PLAYABLE, VIEWEDREPLAYABLE, PLAYING, VIEWEDNOTREPLAYABLE
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class MediaReferenceType {
|
||||||
|
UNASSIGNED, OVERLAY, IMAGE, VIDEO, ASSET_BUNDLE, AUDIO, ANIMATED_IMAGE, FONT, WEB_VIEW_CONTENT, VIDEO_NO_AUDIO
|
||||||
|
}
|
@ -0,0 +1,29 @@
|
|||||||
|
package me.rhunk.snapenhance.data.wrapper
|
||||||
|
|
||||||
|
import de.robv.android.xposed.XposedHelpers
|
||||||
|
|
||||||
|
abstract class AbstractWrapper(
|
||||||
|
protected var instance: Any
|
||||||
|
) {
|
||||||
|
fun instance() = instance
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
return instance.hashCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString(): String {
|
||||||
|
return instance.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun <T : Enum<*>> getEnumValue(fieldName: String, defaultValue: T): T {
|
||||||
|
val mContentType = XposedHelpers.getObjectField(instance, fieldName) as Enum<*>
|
||||||
|
return java.lang.Enum.valueOf(defaultValue::class.java, mContentType.name) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
fun setEnumValue(fieldName: String, value: Enum<*>) {
|
||||||
|
val type = instance.javaClass.fields.find { it.name == fieldName }?.type as Class<out Enum<*>>
|
||||||
|
XposedHelpers.setObjectField(instance, fieldName, java.lang.Enum.valueOf(type, value.name))
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,12 @@
|
|||||||
|
package me.rhunk.snapenhance.data.wrapper.impl
|
||||||
|
|
||||||
|
import me.rhunk.snapenhance.data.wrapper.AbstractWrapper
|
||||||
|
import me.rhunk.snapenhance.util.getObjectField
|
||||||
|
|
||||||
|
class Message(obj: Any) : AbstractWrapper(obj) {
|
||||||
|
val orderKey get() = instance.getObjectField("mOrderKey") as Long
|
||||||
|
val senderId get() = SnapUUID(instance.getObjectField("mSenderId"))
|
||||||
|
val messageContent get() = MessageContent(instance.getObjectField("mMessageContent"))
|
||||||
|
val messageDescriptor get() = MessageDescriptor(instance.getObjectField("mDescriptor"))
|
||||||
|
val messageMetadata get() = MessageMetadata(instance.getObjectField("mMetadata"))
|
||||||
|
}
|
@ -0,0 +1,15 @@
|
|||||||
|
package me.rhunk.snapenhance.data.wrapper.impl
|
||||||
|
|
||||||
|
import me.rhunk.snapenhance.data.ContentType
|
||||||
|
import me.rhunk.snapenhance.data.wrapper.AbstractWrapper
|
||||||
|
import me.rhunk.snapenhance.util.getObjectField
|
||||||
|
import me.rhunk.snapenhance.util.setObjectField
|
||||||
|
|
||||||
|
class MessageContent(obj: Any) : AbstractWrapper(obj) {
|
||||||
|
var content
|
||||||
|
get() = instance.getObjectField("mContent") as ByteArray
|
||||||
|
set(value) = instance.setObjectField("mContent", value)
|
||||||
|
var contentType
|
||||||
|
get() = getEnumValue("mContentType", ContentType.UNKNOWN)
|
||||||
|
set(value) = setEnumValue("mContentType", value)
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
package me.rhunk.snapenhance.data.wrapper.impl
|
||||||
|
|
||||||
|
import me.rhunk.snapenhance.data.wrapper.AbstractWrapper
|
||||||
|
import me.rhunk.snapenhance.util.getObjectField
|
||||||
|
|
||||||
|
class MessageDescriptor(obj: Any) : AbstractWrapper(obj) {
|
||||||
|
val messageId: Long get() = instance.getObjectField("mMessageId") as Long
|
||||||
|
val conversationId: SnapUUID get() = SnapUUID(instance.getObjectField("mConversationId"))
|
||||||
|
}
|
@ -0,0 +1,16 @@
|
|||||||
|
package me.rhunk.snapenhance.data.wrapper.impl
|
||||||
|
|
||||||
|
import me.rhunk.snapenhance.data.PlayableSnapState
|
||||||
|
import me.rhunk.snapenhance.data.wrapper.AbstractWrapper
|
||||||
|
import me.rhunk.snapenhance.util.getObjectField
|
||||||
|
|
||||||
|
class MessageMetadata(obj: Any) : AbstractWrapper(obj){
|
||||||
|
val createdAt: Long get() = instance.getObjectField("mCreatedAt") as Long
|
||||||
|
val readAt: Long get() = instance.getObjectField("mReadAt") as Long
|
||||||
|
var playableSnapState: PlayableSnapState
|
||||||
|
get() = getEnumValue("mPlayableSnapState", PlayableSnapState.PLAYABLE)
|
||||||
|
set(value) {
|
||||||
|
setEnumValue("mPlayableSnapState", value)
|
||||||
|
}
|
||||||
|
val savedBy: List<SnapUUID> = (instance.getObjectField("mSavedBy") as List<*>).map { SnapUUID(it!!) }
|
||||||
|
}
|
@ -0,0 +1,40 @@
|
|||||||
|
package me.rhunk.snapenhance.data.wrapper.impl
|
||||||
|
|
||||||
|
import me.rhunk.snapenhance.SnapEnhance
|
||||||
|
import me.rhunk.snapenhance.data.wrapper.AbstractWrapper
|
||||||
|
import me.rhunk.snapenhance.util.getObjectField
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
class SnapUUID(instance: Any) : AbstractWrapper(instance) {
|
||||||
|
private val uuidString by lazy { toUUID().toString() }
|
||||||
|
|
||||||
|
val bytes: ByteArray get() {
|
||||||
|
return instance.getObjectField("mId") as ByteArray
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toUUID(): UUID {
|
||||||
|
val buffer = ByteBuffer.wrap(bytes)
|
||||||
|
return UUID(buffer.long, buffer.long)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString(): String {
|
||||||
|
return uuidString
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromString(uuid: String): SnapUUID {
|
||||||
|
return fromUUID(UUID.fromString(uuid))
|
||||||
|
}
|
||||||
|
fun fromBytes(bytes: ByteArray): SnapUUID {
|
||||||
|
val constructor = SnapEnhance.classCache.snapUUID.getConstructor(ByteArray::class.java)
|
||||||
|
return SnapUUID(constructor.newInstance(bytes))
|
||||||
|
}
|
||||||
|
fun fromUUID(uuid: UUID): SnapUUID {
|
||||||
|
val buffer = ByteBuffer.allocate(16)
|
||||||
|
buffer.putLong(uuid.mostSignificantBits)
|
||||||
|
buffer.putLong(uuid.leastSignificantBits)
|
||||||
|
return fromBytes(buffer.array())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,73 @@
|
|||||||
|
package me.rhunk.snapenhance.data.wrapper.impl.media
|
||||||
|
|
||||||
|
import me.rhunk.snapenhance.data.wrapper.AbstractWrapper
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.lang.reflect.Field
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.CipherInputStream
|
||||||
|
import javax.crypto.CipherOutputStream
|
||||||
|
import javax.crypto.spec.IvParameterSpec
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
|
||||||
|
class EncryptionWrapper(instance: Any) : AbstractWrapper(instance) {
|
||||||
|
fun decrypt(data: ByteArray?): ByteArray {
|
||||||
|
return newCipher(Cipher.DECRYPT_MODE).doFinal(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decrypt(inputStream: InputStream?): InputStream {
|
||||||
|
return CipherInputStream(inputStream, newCipher(Cipher.DECRYPT_MODE))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decrypt(outputStream: OutputStream?): OutputStream {
|
||||||
|
return CipherOutputStream(outputStream, newCipher(Cipher.DECRYPT_MODE))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for a byte[] field with the specified length
|
||||||
|
*
|
||||||
|
* @param arrayLength the length of the byte[] field
|
||||||
|
* @return the field
|
||||||
|
*/
|
||||||
|
private fun searchByteArrayField(arrayLength: Int): Field {
|
||||||
|
return instance::class.java.fields.first { f ->
|
||||||
|
try {
|
||||||
|
if (!f.type.isArray || f.type
|
||||||
|
.componentType != Byte::class.javaPrimitiveType
|
||||||
|
) return@first false
|
||||||
|
return@first (f.get(instance) as ByteArray).size == arrayLength
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return@first false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new cipher with the specified mode
|
||||||
|
*/
|
||||||
|
fun newCipher(mode: Int): Cipher {
|
||||||
|
val cipher = cipher
|
||||||
|
cipher.init(mode, SecretKeySpec(keySpec, "AES"), IvParameterSpec(ivKeyParameterSpec))
|
||||||
|
return cipher
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the cipher from the encryption wrapper
|
||||||
|
*/
|
||||||
|
private val cipher: Cipher
|
||||||
|
get() = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the key spec from the encryption wrapper
|
||||||
|
*/
|
||||||
|
val keySpec: ByteArray by lazy {
|
||||||
|
searchByteArrayField(32)[instance] as ByteArray
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the iv key parameter spec from the encryption wrapper
|
||||||
|
*/
|
||||||
|
val ivKeyParameterSpec: ByteArray by lazy {
|
||||||
|
searchByteArrayField(16)[instance] as ByteArray
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,34 @@
|
|||||||
|
package me.rhunk.snapenhance.data.wrapper.impl.media
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
|
import me.rhunk.snapenhance.data.wrapper.AbstractWrapper
|
||||||
|
import me.rhunk.snapenhance.util.getObjectField
|
||||||
|
import java.lang.reflect.Field
|
||||||
|
|
||||||
|
|
||||||
|
class MediaInfo(obj: Any) : AbstractWrapper(obj) {
|
||||||
|
val uri: String
|
||||||
|
get() {
|
||||||
|
val firstStringUriField = instance.javaClass.fields.first { f: Field -> f.type == String::class.java }
|
||||||
|
return instance.getObjectField(firstStringUriField.name) as String
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
var mediaInfo: Any = instance
|
||||||
|
if (mediaInfo is List<*>) {
|
||||||
|
if (mediaInfo.size == 0) {
|
||||||
|
throw RuntimeException("MediaInfo is empty")
|
||||||
|
}
|
||||||
|
mediaInfo = mediaInfo[0]!!
|
||||||
|
}
|
||||||
|
instance = mediaInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
val encryption: EncryptionWrapper?
|
||||||
|
get() {
|
||||||
|
val encryptionAlgorithmField = instance.javaClass.fields.first { f: Field ->
|
||||||
|
f.type.isInterface && Parcelable::class.java.isAssignableFrom(f.type)
|
||||||
|
}
|
||||||
|
return encryptionAlgorithmField[instance]?.let { EncryptionWrapper(it) }
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,21 @@
|
|||||||
|
package me.rhunk.snapenhance.data.wrapper.impl.media.opera
|
||||||
|
|
||||||
|
import me.rhunk.snapenhance.data.wrapper.AbstractWrapper
|
||||||
|
import me.rhunk.snapenhance.util.ReflectionHelper
|
||||||
|
|
||||||
|
class Layer(obj: Any) : AbstractWrapper(obj) {
|
||||||
|
val paramMap: ParamMap
|
||||||
|
get() {
|
||||||
|
val layerControllerField = ReflectionHelper.searchFieldContainsToString(
|
||||||
|
instance::class.java,
|
||||||
|
instance,
|
||||||
|
"OperaPageModel"
|
||||||
|
)!!
|
||||||
|
|
||||||
|
val paramsMapHashMap = ReflectionHelper.searchFieldStartsWithToString(
|
||||||
|
layerControllerField.type,
|
||||||
|
layerControllerField[instance] as Any, "OperaPageModel"
|
||||||
|
)!!
|
||||||
|
return ParamMap(paramsMapHashMap[layerControllerField[instance]]!!)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,18 @@
|
|||||||
|
package me.rhunk.snapenhance.data.wrapper.impl.media.opera
|
||||||
|
|
||||||
|
import de.robv.android.xposed.XposedHelpers
|
||||||
|
import me.rhunk.snapenhance.data.wrapper.AbstractWrapper
|
||||||
|
import me.rhunk.snapenhance.util.ReflectionHelper
|
||||||
|
import java.lang.reflect.Field
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
class LayerController(obj: Any) : AbstractWrapper(obj) {
|
||||||
|
val paramMap: ParamMap
|
||||||
|
get() {
|
||||||
|
val paramMapField: Field = ReflectionHelper.searchFieldTypeInSuperClasses(
|
||||||
|
instance::class.java,
|
||||||
|
ConcurrentHashMap::class.java
|
||||||
|
) ?: throw RuntimeException("Could not find paramMap field")
|
||||||
|
return ParamMap(XposedHelpers.getObjectField(instance, paramMapField.name))
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,37 @@
|
|||||||
|
package me.rhunk.snapenhance.data.wrapper.impl.media.opera
|
||||||
|
|
||||||
|
import me.rhunk.snapenhance.data.wrapper.AbstractWrapper
|
||||||
|
import me.rhunk.snapenhance.util.ReflectionHelper
|
||||||
|
import me.rhunk.snapenhance.util.getObjectField
|
||||||
|
import java.lang.reflect.Field
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
class ParamMap(obj: Any) : AbstractWrapper(obj) {
|
||||||
|
private val paramMapField: Field by lazy {
|
||||||
|
ReflectionHelper.searchFieldTypeInSuperClasses(
|
||||||
|
instance.javaClass,
|
||||||
|
ConcurrentHashMap::class.java
|
||||||
|
)!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private val concurrentHashMap: ConcurrentHashMap<Any, Any>
|
||||||
|
get() = instance.getObjectField(paramMapField.name) as ConcurrentHashMap<Any, Any>
|
||||||
|
|
||||||
|
operator fun get(key: String): Any? {
|
||||||
|
return concurrentHashMap.keys.firstOrNull{ k: Any -> k.toString() == key }?.let { concurrentHashMap[it] }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun put(key: String, value: Any) {
|
||||||
|
val keyObject = concurrentHashMap.keys.firstOrNull { k: Any -> k.toString() == key } ?: key
|
||||||
|
concurrentHashMap[keyObject] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun containsKey(key: String): Boolean {
|
||||||
|
return concurrentHashMap.keys.any { k: Any -> k.toString() == key }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString(): String {
|
||||||
|
return concurrentHashMap.toString()
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,199 @@
|
|||||||
|
package me.rhunk.snapenhance.database
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.database.sqlite.SQLiteDatabase
|
||||||
|
import de.robv.android.xposed.XposedBridge
|
||||||
|
import me.rhunk.snapenhance.Logger
|
||||||
|
import me.rhunk.snapenhance.ModContext
|
||||||
|
import me.rhunk.snapenhance.database.objects.*
|
||||||
|
import me.rhunk.snapenhance.manager.Manager
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
@SuppressLint("Range")
|
||||||
|
class DatabaseAccess(private val context: ModContext) : Manager {
|
||||||
|
private val databaseLock = Any()
|
||||||
|
|
||||||
|
private val arroyoDatabase: File by lazy {
|
||||||
|
context.androidContext.getDatabasePath("arroyo.db")
|
||||||
|
}
|
||||||
|
|
||||||
|
private val mainDatabase: File by lazy {
|
||||||
|
context.androidContext.getDatabasePath("main.db")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openMain(): SQLiteDatabase {
|
||||||
|
return SQLiteDatabase.openDatabase(
|
||||||
|
mainDatabase.absolutePath,
|
||||||
|
null,
|
||||||
|
SQLiteDatabase.OPEN_READONLY
|
||||||
|
)!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openArroyo(): SQLiteDatabase {
|
||||||
|
return SQLiteDatabase.openDatabase(
|
||||||
|
arroyoDatabase.absolutePath,
|
||||||
|
null,
|
||||||
|
SQLiteDatabase.OPEN_READONLY
|
||||||
|
)!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> safeDatabaseOperation(
|
||||||
|
database: SQLiteDatabase,
|
||||||
|
query: (SQLiteDatabase) -> T?
|
||||||
|
): T? {
|
||||||
|
synchronized(databaseLock) {
|
||||||
|
return runCatching {
|
||||||
|
query(database)
|
||||||
|
}.onFailure {
|
||||||
|
Logger.xposedLog("Database operation failed", it)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T : DatabaseObject> readDatabaseObject(
|
||||||
|
obj: T,
|
||||||
|
database: SQLiteDatabase,
|
||||||
|
table: String,
|
||||||
|
where: String,
|
||||||
|
args: Array<String>
|
||||||
|
): T? {
|
||||||
|
val cursor = database.rawQuery("SELECT * FROM $table WHERE $where", args)
|
||||||
|
if (!cursor.moveToFirst()) {
|
||||||
|
cursor.close()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
obj.write(cursor)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.xposedLog(e)
|
||||||
|
}
|
||||||
|
cursor.close()
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getFriendFeedInfoByUserId(userId: String): FriendFeedInfo? {
|
||||||
|
return safeDatabaseOperation(openMain()) { database ->
|
||||||
|
readDatabaseObject(
|
||||||
|
FriendFeedInfo(),
|
||||||
|
database,
|
||||||
|
"FriendsFeedView",
|
||||||
|
"friendUserId = ?",
|
||||||
|
arrayOf(userId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getFriendFeedInfoByConversationId(conversationId: String): FriendFeedInfo? {
|
||||||
|
return safeDatabaseOperation(openMain()) {
|
||||||
|
readDatabaseObject(
|
||||||
|
FriendFeedInfo(),
|
||||||
|
it,
|
||||||
|
"FriendsFeedView",
|
||||||
|
"key = ?",
|
||||||
|
arrayOf(conversationId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getFriendInfo(userId: String): FriendInfo? {
|
||||||
|
return safeDatabaseOperation(openMain()) {
|
||||||
|
readDatabaseObject(
|
||||||
|
FriendInfo(),
|
||||||
|
it,
|
||||||
|
"FriendWithUsername",
|
||||||
|
"userId = ?",
|
||||||
|
arrayOf(userId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getConversationMessageFromId(clientMessageId: Long): ConversationMessage? {
|
||||||
|
return safeDatabaseOperation(openArroyo()) {
|
||||||
|
readDatabaseObject(
|
||||||
|
ConversationMessage(),
|
||||||
|
it,
|
||||||
|
"conversation_message",
|
||||||
|
"client_message_id = ?",
|
||||||
|
arrayOf(clientMessageId.toString())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDMConversationIdFromUserId(userId: String): UserConversationLink? {
|
||||||
|
return safeDatabaseOperation(openArroyo()) {
|
||||||
|
readDatabaseObject(
|
||||||
|
UserConversationLink(),
|
||||||
|
it,
|
||||||
|
"user_conversation",
|
||||||
|
"user_id = ? AND conversation_type = 0",
|
||||||
|
arrayOf(userId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getStoryEntryFromId(storyId: String): StoryEntry? {
|
||||||
|
return safeDatabaseOperation(openMain()) {
|
||||||
|
readDatabaseObject(StoryEntry(), it, "Story", "storyId = ?", arrayOf(storyId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getConversationParticipants(conversationId: String): List<String>? {
|
||||||
|
return safeDatabaseOperation(openArroyo()) { arroyoDatabase: SQLiteDatabase ->
|
||||||
|
val cursor = arroyoDatabase.rawQuery(
|
||||||
|
"SELECT * FROM user_conversation WHERE client_conversation_id = ?",
|
||||||
|
arrayOf(conversationId)
|
||||||
|
)
|
||||||
|
if (!cursor.moveToFirst()) {
|
||||||
|
cursor.close()
|
||||||
|
return@safeDatabaseOperation emptyList()
|
||||||
|
}
|
||||||
|
val participants = mutableListOf<String>()
|
||||||
|
do {
|
||||||
|
participants.add(cursor.getString(cursor.getColumnIndex("user_id")))
|
||||||
|
} while (cursor.moveToNext())
|
||||||
|
cursor.close()
|
||||||
|
participants
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMyUserId(): String? {
|
||||||
|
return safeDatabaseOperation(openArroyo()) { arroyoDatabase: SQLiteDatabase ->
|
||||||
|
val cursor = arroyoDatabase.rawQuery(buildString {
|
||||||
|
append("SELECT * FROM required_values WHERE key = 'USERID'")
|
||||||
|
}, null)
|
||||||
|
|
||||||
|
if (!cursor.moveToFirst()) {
|
||||||
|
cursor.close()
|
||||||
|
return@safeDatabaseOperation null
|
||||||
|
}
|
||||||
|
|
||||||
|
val userId = cursor.getString(cursor.getColumnIndex("value"))
|
||||||
|
cursor.close()
|
||||||
|
userId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMessagesFromConversationId(
|
||||||
|
conversationId: String,
|
||||||
|
limit: Int
|
||||||
|
): List<ConversationMessage>? {
|
||||||
|
return safeDatabaseOperation(openArroyo()) { arroyoDatabase: SQLiteDatabase ->
|
||||||
|
val cursor = arroyoDatabase.rawQuery(
|
||||||
|
"SELECT * FROM conversation_message WHERE client_conversation_id = ? ORDER BY creation_timestamp DESC LIMIT ?",
|
||||||
|
arrayOf(conversationId, limit.toString())
|
||||||
|
)
|
||||||
|
if (!cursor.moveToFirst()) {
|
||||||
|
cursor.close()
|
||||||
|
return@safeDatabaseOperation emptyList()
|
||||||
|
}
|
||||||
|
val messages = mutableListOf<ConversationMessage>()
|
||||||
|
do {
|
||||||
|
val message = ConversationMessage()
|
||||||
|
message.write(cursor)
|
||||||
|
messages.add(message)
|
||||||
|
} while (cursor.moveToNext())
|
||||||
|
cursor.close()
|
||||||
|
messages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
package me.rhunk.snapenhance.database
|
||||||
|
|
||||||
|
import android.database.Cursor
|
||||||
|
|
||||||
|
interface DatabaseObject {
|
||||||
|
fun write(cursor: Cursor)
|
||||||
|
}
|
@ -0,0 +1,44 @@
|
|||||||
|
package me.rhunk.snapenhance.database.objects
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.database.Cursor
|
||||||
|
import me.rhunk.snapenhance.Constants
|
||||||
|
import me.rhunk.snapenhance.data.ContentType
|
||||||
|
import me.rhunk.snapenhance.database.DatabaseObject
|
||||||
|
import me.rhunk.snapenhance.util.protobuf.ProtoReader
|
||||||
|
|
||||||
|
@Suppress("ArrayInDataClass")
|
||||||
|
data class ConversationMessage(
|
||||||
|
var client_conversation_id: String? = null,
|
||||||
|
var client_message_id: Int = 0,
|
||||||
|
var server_message_id: Int = 0,
|
||||||
|
var message_content: ByteArray? = null,
|
||||||
|
var is_saved: Int = 0,
|
||||||
|
var is_viewed_by_user: Int = 0,
|
||||||
|
var content_type: Int = 0,
|
||||||
|
var creation_timestamp: Long = 0,
|
||||||
|
var read_timestamp: Long = 0,
|
||||||
|
var sender_id: String? = null
|
||||||
|
) : DatabaseObject {
|
||||||
|
|
||||||
|
@SuppressLint("Range")
|
||||||
|
override fun write(cursor: Cursor) {
|
||||||
|
client_conversation_id = cursor.getString(cursor.getColumnIndex("client_conversation_id"))
|
||||||
|
client_message_id = cursor.getInt(cursor.getColumnIndex("client_message_id"))
|
||||||
|
server_message_id = cursor.getInt(cursor.getColumnIndex("server_message_id"))
|
||||||
|
message_content = cursor.getBlob(cursor.getColumnIndex("message_content"))
|
||||||
|
is_saved = cursor.getInt(cursor.getColumnIndex("is_saved"))
|
||||||
|
is_viewed_by_user = cursor.getInt(cursor.getColumnIndex("is_viewed_by_user"))
|
||||||
|
content_type = cursor.getInt(cursor.getColumnIndex("content_type"))
|
||||||
|
creation_timestamp = cursor.getLong(cursor.getColumnIndex("creation_timestamp"))
|
||||||
|
read_timestamp = cursor.getLong(cursor.getColumnIndex("read_timestamp"))
|
||||||
|
sender_id = cursor.getString(cursor.getColumnIndex("sender_id"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMessageAsString(): String? {
|
||||||
|
return when (ContentType.fromId(content_type)) {
|
||||||
|
ContentType.CHAT -> message_content?.let { ProtoReader(it).getString(*Constants.ARROYO_STRING_CHAT_MESSAGE_PROTO) }
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,33 @@
|
|||||||
|
package me.rhunk.snapenhance.database.objects
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.database.Cursor
|
||||||
|
import me.rhunk.snapenhance.database.DatabaseObject
|
||||||
|
|
||||||
|
data class FriendFeedInfo(
|
||||||
|
var id: Int = 0,
|
||||||
|
var feedDisplayName: String? = null,
|
||||||
|
var participantsSize: Int = 0,
|
||||||
|
var lastInteractionTimestamp: Long = 0,
|
||||||
|
var displayTimestamp: Long = 0,
|
||||||
|
var displayInteractionType: String? = null,
|
||||||
|
var lastInteractionUserId: Int = 0,
|
||||||
|
var key: String? = null,
|
||||||
|
var friendUserId: String? = null,
|
||||||
|
var friendDisplayName: String? = null,
|
||||||
|
) : DatabaseObject {
|
||||||
|
|
||||||
|
@SuppressLint("Range")
|
||||||
|
override fun write(cursor: Cursor) {
|
||||||
|
id = cursor.getInt(cursor.getColumnIndex("_id"))
|
||||||
|
feedDisplayName = cursor.getString(cursor.getColumnIndex("feedDisplayName"))
|
||||||
|
participantsSize = cursor.getInt(cursor.getColumnIndex("participantsSize"))
|
||||||
|
lastInteractionTimestamp = cursor.getLong(cursor.getColumnIndex("lastInteractionTimestamp"))
|
||||||
|
displayTimestamp = cursor.getLong(cursor.getColumnIndex("displayTimestamp"))
|
||||||
|
displayInteractionType = cursor.getString(cursor.getColumnIndex("displayInteractionType"))
|
||||||
|
lastInteractionUserId = cursor.getInt(cursor.getColumnIndex("lastInteractionUserId"))
|
||||||
|
key = cursor.getString(cursor.getColumnIndex("key"))
|
||||||
|
friendUserId = cursor.getString(cursor.getColumnIndex("friendUserId"))
|
||||||
|
friendDisplayName = cursor.getString(cursor.getColumnIndex("friendDisplayUsername"))
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,58 @@
|
|||||||
|
package me.rhunk.snapenhance.database.objects
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.database.Cursor
|
||||||
|
import me.rhunk.snapenhance.database.DatabaseObject
|
||||||
|
|
||||||
|
data class FriendInfo(
|
||||||
|
var id: Int = 0,
|
||||||
|
var lastModifiedTimestamp: Long = 0,
|
||||||
|
var username: String? = null,
|
||||||
|
var userId: String? = null,
|
||||||
|
var displayName: String? = null,
|
||||||
|
var bitmojiAvatarId: String? = null,
|
||||||
|
var bitmojiSelfieId: String? = null,
|
||||||
|
var bitmojiSceneId: String? = null,
|
||||||
|
var bitmojiBackgroundId: String? = null,
|
||||||
|
var friendmojis: String? = null,
|
||||||
|
var friendmojiCategories: String? = null,
|
||||||
|
var snapScore: Int = 0,
|
||||||
|
var birthday: Long = 0,
|
||||||
|
var addedTimestamp: Long = 0,
|
||||||
|
var reverseAddedTimestamp: Long = 0,
|
||||||
|
var serverDisplayName: String? = null,
|
||||||
|
var streakLength: Int = 0,
|
||||||
|
var streakExpirationTimestamp: Long = 0,
|
||||||
|
var reverseBestFriendRanking: Int = 0,
|
||||||
|
var isPinnedBestFriend: Int = 0,
|
||||||
|
var plusBadgeVisibility: Int = 0,
|
||||||
|
var usernameForSorting: String? = null
|
||||||
|
) : DatabaseObject {
|
||||||
|
@SuppressLint("Range")
|
||||||
|
override fun write(cursor: Cursor) {
|
||||||
|
id = cursor.getInt(cursor.getColumnIndex("_id"))
|
||||||
|
lastModifiedTimestamp = cursor.getLong(cursor.getColumnIndex("_lastModifiedTimestamp"))
|
||||||
|
username = cursor.getString(cursor.getColumnIndex("username"))
|
||||||
|
userId = cursor.getString(cursor.getColumnIndex("userId"))
|
||||||
|
displayName = cursor.getString(cursor.getColumnIndex("displayName"))
|
||||||
|
bitmojiAvatarId = cursor.getString(cursor.getColumnIndex("bitmojiAvatarId"))
|
||||||
|
bitmojiSelfieId = cursor.getString(cursor.getColumnIndex("bitmojiSelfieId"))
|
||||||
|
bitmojiSceneId = cursor.getString(cursor.getColumnIndex("bitmojiSceneId"))
|
||||||
|
bitmojiBackgroundId = cursor.getString(cursor.getColumnIndex("bitmojiBackgroundId"))
|
||||||
|
friendmojis = cursor.getString(cursor.getColumnIndex("friendmojis"))
|
||||||
|
friendmojiCategories = cursor.getString(cursor.getColumnIndex("friendmojiCategories"))
|
||||||
|
snapScore = cursor.getInt(cursor.getColumnIndex("score"))
|
||||||
|
birthday = cursor.getLong(cursor.getColumnIndex("birthday"))
|
||||||
|
addedTimestamp = cursor.getLong(cursor.getColumnIndex("addedTimestamp"))
|
||||||
|
reverseAddedTimestamp = cursor.getLong(cursor.getColumnIndex("reverseAddedTimestamp"))
|
||||||
|
serverDisplayName = cursor.getString(cursor.getColumnIndex("serverDisplayName"))
|
||||||
|
streakLength = cursor.getInt(cursor.getColumnIndex("streakLength"))
|
||||||
|
streakExpirationTimestamp = cursor.getLong(cursor.getColumnIndex("streakExpiration"))
|
||||||
|
reverseBestFriendRanking = cursor.getInt(cursor.getColumnIndex("reverseBestFriendRanking"))
|
||||||
|
usernameForSorting = cursor.getString(cursor.getColumnIndex("usernameForSorting"))
|
||||||
|
if (cursor.getColumnIndex("isPinnedBestFriend") != -1) isPinnedBestFriend =
|
||||||
|
cursor.getInt(cursor.getColumnIndex("isPinnedBestFriend"))
|
||||||
|
if (cursor.getColumnIndex("plusBadgeVisibility") != -1) plusBadgeVisibility =
|
||||||
|
cursor.getInt(cursor.getColumnIndex("plusBadgeVisibility"))
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,23 @@
|
|||||||
|
package me.rhunk.snapenhance.database.objects
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.database.Cursor
|
||||||
|
import me.rhunk.snapenhance.database.DatabaseObject
|
||||||
|
|
||||||
|
data class StoryEntry(
|
||||||
|
var id: Int = 0,
|
||||||
|
var storyId: String? = null,
|
||||||
|
var displayName: String? = null,
|
||||||
|
var isLocal: Boolean? = null,
|
||||||
|
var userId: String? = null
|
||||||
|
) : DatabaseObject {
|
||||||
|
|
||||||
|
@SuppressLint("Range")
|
||||||
|
override fun write(cursor: Cursor) {
|
||||||
|
id = cursor.getInt(cursor.getColumnIndex("_id"))
|
||||||
|
storyId = cursor.getString(cursor.getColumnIndex("storyId"))
|
||||||
|
displayName = cursor.getString(cursor.getColumnIndex("displayName"))
|
||||||
|
isLocal = cursor.getInt(cursor.getColumnIndex("isLocal")) == 1
|
||||||
|
userId = cursor.getString(cursor.getColumnIndex("userId"))
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,19 @@
|
|||||||
|
package me.rhunk.snapenhance.database.objects
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.database.Cursor
|
||||||
|
import me.rhunk.snapenhance.database.DatabaseObject
|
||||||
|
|
||||||
|
class UserConversationLink(
|
||||||
|
var user_id: String? = null,
|
||||||
|
var client_conversation_id: String? = null,
|
||||||
|
var conversation_type: Int = 0
|
||||||
|
) : DatabaseObject {
|
||||||
|
|
||||||
|
@SuppressLint("Range")
|
||||||
|
override fun write(cursor: Cursor) {
|
||||||
|
user_id = cursor.getString(cursor.getColumnIndex("user_id"))
|
||||||
|
client_conversation_id = cursor.getString(cursor.getColumnIndex("client_conversation_id"))
|
||||||
|
conversation_type = cursor.getInt(cursor.getColumnIndex("conversation_type"))
|
||||||
|
}
|
||||||
|
}
|
62
app/src/main/kotlin/me/rhunk/snapenhance/event/EventBus.kt
Normal file
62
app/src/main/kotlin/me/rhunk/snapenhance/event/EventBus.kt
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
package me.rhunk.snapenhance.event
|
||||||
|
|
||||||
|
import me.rhunk.snapenhance.ModContext
|
||||||
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
|
abstract class Event {
|
||||||
|
lateinit var context: ModContext
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IListener<T> {
|
||||||
|
fun handle(event: T)
|
||||||
|
}
|
||||||
|
|
||||||
|
class EventBus(
|
||||||
|
private val context: ModContext
|
||||||
|
) {
|
||||||
|
private val subscribers = mutableMapOf<KClass<out Event>, MutableList<IListener<out Event>>>()
|
||||||
|
|
||||||
|
fun <T : Event> subscribe(event: KClass<T>, listener: IListener<T>) {
|
||||||
|
if (!subscribers.containsKey(event)) {
|
||||||
|
subscribers[event] = mutableListOf()
|
||||||
|
}
|
||||||
|
subscribers[event]!!.add(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T : Event> subscribe(event: KClass<T>, listener: (T) -> Unit) {
|
||||||
|
subscribe(event, object : IListener<T> {
|
||||||
|
override fun handle(event: T) {
|
||||||
|
listener(event)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T : Event> unsubscribe(event: KClass<T>, listener: IListener<T>) {
|
||||||
|
if (!subscribers.containsKey(event)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
subscribers[event]!!.remove(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T : Event> post(event: T) {
|
||||||
|
if (!subscribers.containsKey(event::class)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.context = context
|
||||||
|
|
||||||
|
subscribers[event::class]!!.forEach { listener ->
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
try {
|
||||||
|
(listener as IListener<T>).handle(event)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
println("Error while handling event ${event::class.simpleName} by ${listener::class.simpleName}")
|
||||||
|
t.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
subscribers.clear()
|
||||||
|
}
|
||||||
|
}
|
3
app/src/main/kotlin/me/rhunk/snapenhance/event/Events.kt
Normal file
3
app/src/main/kotlin/me/rhunk/snapenhance/event/Events.kt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
package me.rhunk.snapenhance.event
|
||||||
|
|
||||||
|
//TODO: addView event
|
31
app/src/main/kotlin/me/rhunk/snapenhance/features/Feature.kt
Normal file
31
app/src/main/kotlin/me/rhunk/snapenhance/features/Feature.kt
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
package me.rhunk.snapenhance.features
|
||||||
|
|
||||||
|
import me.rhunk.snapenhance.ModContext
|
||||||
|
|
||||||
|
abstract class Feature(
|
||||||
|
val nameKey: String,
|
||||||
|
val loadParams: Int = FeatureLoadParams.INIT_SYNC
|
||||||
|
) {
|
||||||
|
lateinit var context: ModContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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() {}
|
||||||
|
}
|
@ -0,0 +1,11 @@
|
|||||||
|
package me.rhunk.snapenhance.features
|
||||||
|
|
||||||
|
object FeatureLoadParams {
|
||||||
|
const val NO_INIT = 0
|
||||||
|
|
||||||
|
const val INIT_SYNC = 1
|
||||||
|
const val ACTIVITY_CREATE_SYNC = 2
|
||||||
|
|
||||||
|
const val INIT_ASYNC = 3
|
||||||
|
const val ACTIVITY_CREATE_ASYNC = 4
|
||||||
|
}
|
@ -0,0 +1,67 @@
|
|||||||
|
package me.rhunk.snapenhance.features.impl
|
||||||
|
|
||||||
|
import me.rhunk.snapenhance.config.ConfigProperty
|
||||||
|
import me.rhunk.snapenhance.features.Feature
|
||||||
|
import me.rhunk.snapenhance.features.FeatureLoadParams
|
||||||
|
import me.rhunk.snapenhance.util.setObjectField
|
||||||
|
import java.lang.reflect.Field
|
||||||
|
import java.lang.reflect.Modifier
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
|
||||||
|
class ConfigEnumKeys : Feature("Config enum keys", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
|
||||||
|
private fun hookAllEnums(enumClass: Class<*>, callback: (String, AtomicReference<Any>) -> Unit) {
|
||||||
|
//Enum(String, int, ?)
|
||||||
|
//or Enum(?)
|
||||||
|
val enumDataClass = enumClass.constructors[0].parameterTypes.first { clazz: Class<*> -> clazz != String::class.java && !clazz.isPrimitive }
|
||||||
|
|
||||||
|
//get the field which contains the enum data class
|
||||||
|
val enumDataField = enumClass.declaredFields.first { field: Field -> field.type == enumDataClass }
|
||||||
|
|
||||||
|
//get the field value of the enum data class (the first field of the class with the desc Object)
|
||||||
|
val objectDataField = enumDataField.type.fields.first { field: Field ->
|
||||||
|
field.type == Any::class.java && Modifier.isPublic(
|
||||||
|
field.modifiers
|
||||||
|
) && Modifier.isFinal(field.modifiers)
|
||||||
|
}
|
||||||
|
|
||||||
|
enumClass.enumConstants.forEach { enum ->
|
||||||
|
enumDataField.get(enum)?.let { enumData ->
|
||||||
|
val key = objectDataField.get(enumData)!!.toString()
|
||||||
|
val value = AtomicReference(objectDataField.get(enumData))
|
||||||
|
callback(key, value)
|
||||||
|
enumData.setObjectField(objectDataField.name, value.get())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityCreate() {
|
||||||
|
if (context.config.bool(ConfigProperty.NEW_MAP_UI)) {
|
||||||
|
hookAllEnums(context.mappings.getMappedClass("enums", "PLUS")) { key, atomicValue ->
|
||||||
|
if (key == "REDUCE_MY_PROFILE_UI_COMPLEXITY") atomicValue.set(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.config.bool(ConfigProperty.LONG_SNAP_SENDING)) {
|
||||||
|
hookAllEnums(context.mappings.getMappedClass("enums", "ARROYO")) { key, atomicValue ->
|
||||||
|
if (key == "ENABLE_LONG_SNAP_SENDING") atomicValue.set(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.config.bool(ConfigProperty.STREAKEXPIRATIONINFO)) {
|
||||||
|
hookAllEnums(context.mappings.getMappedClass("enums", "FRIENDS_FEED")) { key, atomicValue ->
|
||||||
|
if (key == "STREAK_EXPIRATION_INFO") atomicValue.set(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.config.bool(ConfigProperty.BLOCK_ADS)) {
|
||||||
|
hookAllEnums(context.mappings.getMappedClass("enums", "SNAPADS")) { key, atomicValue ->
|
||||||
|
if (key == "BYPASS_AD_FEATURE_GATE") {
|
||||||
|
atomicValue.set(true)
|
||||||
|
}
|
||||||
|
if (key == "CUSTOM_AD_SERVER_URL" || key == "CUSTOM_AD_INIT_SERVER_URL" || key == "CUSTOM_AD_TRACKER_URL") {
|
||||||
|
atomicValue.set("http://127.0.0.1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,71 @@
|
|||||||
|
package me.rhunk.snapenhance.features.impl
|
||||||
|
|
||||||
|
import me.rhunk.snapenhance.config.ConfigProperty
|
||||||
|
import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID
|
||||||
|
import me.rhunk.snapenhance.features.Feature
|
||||||
|
import me.rhunk.snapenhance.features.FeatureLoadParams
|
||||||
|
import me.rhunk.snapenhance.hook.HookStage
|
||||||
|
import me.rhunk.snapenhance.hook.Hooker
|
||||||
|
|
||||||
|
class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC or FeatureLoadParams.INIT_ASYNC or FeatureLoadParams.INIT_SYNC) {
|
||||||
|
lateinit var conversationManager: Any
|
||||||
|
|
||||||
|
var lastOpenedConversationUUID: SnapUUID? = null
|
||||||
|
var lastFetchConversationUserUUID: SnapUUID? = null
|
||||||
|
var lastFetchConversationUUID: SnapUUID? = null
|
||||||
|
var lastFocusedMessageId: Long = -1
|
||||||
|
|
||||||
|
override fun init() {
|
||||||
|
Hooker.hookConstructor(context.classCache.conversationManager, HookStage.BEFORE) {
|
||||||
|
conversationManager = it.thisObject()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityCreate() {
|
||||||
|
with(context.classCache.conversationManager) {
|
||||||
|
Hooker.hook(this, "enterConversation", HookStage.BEFORE) {
|
||||||
|
lastOpenedConversationUUID = SnapUUID(it.arg(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
Hooker.hook(this, "getOneOnOneConversationIds", HookStage.BEFORE) { param ->
|
||||||
|
val conversationIds: List<Any> = param.arg(0)
|
||||||
|
if (conversationIds.isNotEmpty()) {
|
||||||
|
lastFetchConversationUserUUID = SnapUUID(conversationIds[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Hooker.hook(this, "exitConversation", HookStage.BEFORE) {
|
||||||
|
lastOpenedConversationUUID = null
|
||||||
|
}
|
||||||
|
|
||||||
|
Hooker.hook(this, "fetchConversation", HookStage.BEFORE) {
|
||||||
|
lastFetchConversationUUID = SnapUUID(it.arg(0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun asyncInit() {
|
||||||
|
arrayOf("activate", "deactivate", "processTypingActivity").forEach { hook ->
|
||||||
|
Hooker.hook(context.classCache.presenceSession, hook, HookStage.BEFORE, { context.config.bool(ConfigProperty.HIDE_BITMOJI_PRESENCE) }) {
|
||||||
|
it.setResult(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//get last opened snap for media downloader
|
||||||
|
Hooker.hook(context.classCache.snapManager, "onSnapInteraction", HookStage.BEFORE) { param ->
|
||||||
|
lastOpenedConversationUUID = SnapUUID(param.arg(1))
|
||||||
|
lastFocusedMessageId = param.arg(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
Hooker.hook(context.classCache.conversationManager, "fetchMessage", HookStage.BEFORE) { param ->
|
||||||
|
lastFetchConversationUserUUID = SnapUUID((param.arg(0) as Any))
|
||||||
|
lastFocusedMessageId = param.arg(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Hooker.hook(context.classCache.conversationManager, "sendTypingNotification", HookStage.BEFORE,
|
||||||
|
{context.config.bool(ConfigProperty.HIDE_TYPING_NOTIFICATION)}) {
|
||||||
|
it.setResult(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,430 @@
|
|||||||
|
package me.rhunk.snapenhance.features.impl.downloader
|
||||||
|
|
||||||
|
import android.app.AlertDialog
|
||||||
|
import android.content.DialogInterface
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.media.MediaScannerConnection
|
||||||
|
import android.net.Uri
|
||||||
|
import android.widget.ImageView
|
||||||
|
import com.arthenica.ffmpegkit.FFmpegKit
|
||||||
|
import de.robv.android.xposed.XposedBridge
|
||||||
|
import me.rhunk.snapenhance.Constants
|
||||||
|
import me.rhunk.snapenhance.Constants.ARROYO_URL_KEY_PROTO_PATH
|
||||||
|
import me.rhunk.snapenhance.Logger
|
||||||
|
import me.rhunk.snapenhance.Logger.xposedLog
|
||||||
|
import me.rhunk.snapenhance.config.ConfigProperty
|
||||||
|
import me.rhunk.snapenhance.data.ContentType
|
||||||
|
import me.rhunk.snapenhance.data.FileType
|
||||||
|
import me.rhunk.snapenhance.data.wrapper.impl.media.MediaInfo
|
||||||
|
import me.rhunk.snapenhance.data.wrapper.impl.media.opera.Layer
|
||||||
|
import me.rhunk.snapenhance.data.wrapper.impl.media.opera.ParamMap
|
||||||
|
import me.rhunk.snapenhance.features.Feature
|
||||||
|
import me.rhunk.snapenhance.features.FeatureLoadParams
|
||||||
|
import me.rhunk.snapenhance.features.impl.Messaging
|
||||||
|
import me.rhunk.snapenhance.features.impl.spy.MessageLogger
|
||||||
|
import me.rhunk.snapenhance.hook.HookAdapter
|
||||||
|
import me.rhunk.snapenhance.hook.HookStage
|
||||||
|
import me.rhunk.snapenhance.hook.Hooker
|
||||||
|
import me.rhunk.snapenhance.util.EncryptionUtils
|
||||||
|
import me.rhunk.snapenhance.util.PreviewUtils
|
||||||
|
import me.rhunk.snapenhance.util.download.CdnDownloader
|
||||||
|
import me.rhunk.snapenhance.util.getObjectField
|
||||||
|
import me.rhunk.snapenhance.util.protobuf.ProtoReader
|
||||||
|
import java.io.*
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.nio.file.Paths
|
||||||
|
import java.util.*
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
import java.util.zip.ZipInputStream
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.CipherInputStream
|
||||||
|
import kotlin.io.path.inputStream
|
||||||
|
|
||||||
|
enum class MediaType {
|
||||||
|
ORIGINAL, OVERLAY
|
||||||
|
}
|
||||||
|
|
||||||
|
class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) {
|
||||||
|
private var lastSeenMediaInfoMap: MutableMap<MediaType, MediaInfo>? = null
|
||||||
|
private var lastSeenMapParams: ParamMap? = null
|
||||||
|
private var isFFmpegPresent: Boolean? = null
|
||||||
|
|
||||||
|
private fun canMergeOverlay(): Boolean {
|
||||||
|
if (!context.config.bool(ConfigProperty.OVERLAY_MERGE)) return false
|
||||||
|
if (isFFmpegPresent != null) {
|
||||||
|
return isFFmpegPresent!!
|
||||||
|
}
|
||||||
|
//check if ffmpeg is correctly installed
|
||||||
|
isFFmpegPresent = runCatching { FFmpegKit.execute("-version") }.isSuccess
|
||||||
|
return isFFmpegPresent!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNewFilePath(hash: Int, author: String, fileType: FileType): String? {
|
||||||
|
val hexHash = Integer.toHexString(hash)
|
||||||
|
return author + "/" + hexHash + "." + fileType.fileExtension
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun downloadFile(outputFile: File, content: ByteArray): Boolean {
|
||||||
|
val onDownloadComplete = {
|
||||||
|
context.shortToast(
|
||||||
|
"Saved to " + outputFile.absolutePath.replace(context.config.string(ConfigProperty.SAVE_FOLDER), "")
|
||||||
|
.substring(1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!context.config.bool(ConfigProperty.USE_DOWNLOAD_MANAGER)) {
|
||||||
|
try {
|
||||||
|
val fos = FileOutputStream(outputFile)
|
||||||
|
fos.write(content)
|
||||||
|
fos.close()
|
||||||
|
MediaScannerConnection.scanFile(
|
||||||
|
context.androidContext,
|
||||||
|
arrayOf(outputFile.absolutePath),
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
onDownloadComplete()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.xposedLog(e)
|
||||||
|
context.longToast("Failed to save file: " + e.message)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
context.downloadServer.startFileDownload(outputFile, content) { result ->
|
||||||
|
if (result) {
|
||||||
|
onDownloadComplete()
|
||||||
|
return@startFileDownload
|
||||||
|
}
|
||||||
|
context.longToast("Failed to save file. Check logs for more info.")
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private fun mergeOverlay(original: ByteArray, overlay: ByteArray, isPreviewMode: Boolean): ByteArray? {
|
||||||
|
context.longToast("Merging current media with overlay. This may take a while.")
|
||||||
|
val originalFileType = FileType.fromByteArray(original)
|
||||||
|
val overlayFileType = FileType.fromByteArray(overlay)
|
||||||
|
//merge files
|
||||||
|
val mergedFile = File.createTempFile("merged", "." + originalFileType.fileExtension)
|
||||||
|
val tempVideoFile = File.createTempFile("original", "." + originalFileType.fileExtension).also {
|
||||||
|
with(FileOutputStream(it)) {
|
||||||
|
write(original)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val tempOverlayFile = File.createTempFile("overlay", "." + overlayFileType.fileExtension).also {
|
||||||
|
with(FileOutputStream(it)) {
|
||||||
|
write(overlay)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//TODO: improve ffmpeg speed
|
||||||
|
val fFmpegSession = FFmpegKit.execute(
|
||||||
|
"-y -i " +
|
||||||
|
tempVideoFile.absolutePath +
|
||||||
|
" -i " +
|
||||||
|
tempOverlayFile.absolutePath +
|
||||||
|
" -filter_complex \"[0]scale2ref[img][vid];[img]setsar=1[img];[vid]nullsink; [img][1]overlay=(W-w)/2:(H-h)/2,scale=2*trunc(iw*sar/2):2*trunc(ih/2)\" -c:v libx264 -q:v 13 -c:a copy " +
|
||||||
|
" -threads 6 ${(if (isPreviewMode) "-frames:v 1" else "")} " +
|
||||||
|
mergedFile.absolutePath
|
||||||
|
)
|
||||||
|
tempVideoFile.delete()
|
||||||
|
tempOverlayFile.delete()
|
||||||
|
if (fFmpegSession.returnCode.value != 0) {
|
||||||
|
mergedFile.delete()
|
||||||
|
context.longToast("Failed to merge video and overlay. See logs for more details.")
|
||||||
|
Logger.xposedLog(fFmpegSession.output)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val mergedFileData: ByteArray = FileInputStream(mergedFile).readBytes()
|
||||||
|
mergedFile.delete()
|
||||||
|
return mergedFileData
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun queryMediaData(mediaInfo: MediaInfo): ByteArray {
|
||||||
|
val mediaUri = Uri.parse(mediaInfo.uri)
|
||||||
|
val mediaInputStream = AtomicReference<InputStream>()
|
||||||
|
if (mediaUri.scheme == "file") {
|
||||||
|
mediaInputStream.set(Paths.get(mediaUri.path).inputStream())
|
||||||
|
} else {
|
||||||
|
val url = URL(mediaUri.toString())
|
||||||
|
val connection = url.openConnection() as HttpURLConnection
|
||||||
|
connection.requestMethod = "GET"
|
||||||
|
connection.setRequestProperty("User-Agent", Constants.USER_AGENT)
|
||||||
|
connection.connect()
|
||||||
|
mediaInputStream.set(connection.inputStream)
|
||||||
|
}
|
||||||
|
mediaInfo.encryption?.let { encryption ->
|
||||||
|
mediaInputStream.set(CipherInputStream(mediaInputStream.get(), encryption.newCipher(Cipher.DECRYPT_MODE)))
|
||||||
|
}
|
||||||
|
return mediaInputStream.get().readBytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNeededDirectories(file: File): File {
|
||||||
|
val directory = file.parentFile ?: return file
|
||||||
|
if (!directory.exists()) {
|
||||||
|
directory.mkdirs()
|
||||||
|
}
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isFileExists(hash: Int, author: String, fileType: FileType): Boolean {
|
||||||
|
val fileName: String = createNewFilePath(hash, author, fileType) ?: return false
|
||||||
|
val outputFile: File =
|
||||||
|
createNeededDirectories(File(context.config.string(ConfigProperty.SAVE_FOLDER), fileName))
|
||||||
|
return outputFile.exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Download the last seen media
|
||||||
|
*/
|
||||||
|
fun downloadLastOperaMediaAsync() {
|
||||||
|
if (lastSeenMapParams == null || lastSeenMediaInfoMap == null) return
|
||||||
|
context.executeAsync {
|
||||||
|
handleOperaMedia(
|
||||||
|
lastSeenMapParams!!,
|
||||||
|
lastSeenMediaInfoMap!!, true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun downloadOperaMedia(mediaInfoMap: Map<MediaType, MediaInfo>, author: String) {
|
||||||
|
if (mediaInfoMap.isEmpty()) return
|
||||||
|
val originalMediaInfo = mediaInfoMap[MediaType.ORIGINAL]!!
|
||||||
|
if (mediaInfoMap.containsKey(MediaType.OVERLAY)) {
|
||||||
|
context.shortToast("Downloading split snap")
|
||||||
|
}
|
||||||
|
var mediaContent: ByteArray? = queryMediaData(originalMediaInfo)
|
||||||
|
val hash = Arrays.hashCode(mediaContent)
|
||||||
|
if (mediaInfoMap.containsKey(MediaType.OVERLAY)) {
|
||||||
|
//prevent converting the same media twice
|
||||||
|
if (isFileExists(hash, author, FileType.fromByteArray(mediaContent!!))) {
|
||||||
|
context.shortToast("Media already exists")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val overlayMediaInfo = mediaInfoMap[MediaType.OVERLAY]!!
|
||||||
|
val overlayContent: ByteArray = queryMediaData(overlayMediaInfo)
|
||||||
|
mediaContent = mergeOverlay(mediaContent, overlayContent, false)
|
||||||
|
}
|
||||||
|
val fileType = FileType.fromByteArray(mediaContent!!)
|
||||||
|
downloadMediaContent(mediaContent, hash, author, fileType)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun downloadMediaContent(
|
||||||
|
data: ByteArray,
|
||||||
|
hash: Int,
|
||||||
|
messageAuthor: String,
|
||||||
|
fileType: FileType
|
||||||
|
): Boolean {
|
||||||
|
val fileName: String = createNewFilePath(hash, messageAuthor, fileType) ?: return false
|
||||||
|
val outputFile: File = createNeededDirectories(File(context.config.string(ConfigProperty.SAVE_FOLDER), fileName))
|
||||||
|
if (outputFile.exists()) {
|
||||||
|
context.shortToast("Media already exists")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return downloadFile(outputFile, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the media from the opera viewer
|
||||||
|
*
|
||||||
|
* @param paramMap the parameters from the opera viewer
|
||||||
|
* @param mediaInfoMap the media info map
|
||||||
|
* @param forceDownload if the media should be downloaded
|
||||||
|
*/
|
||||||
|
private fun handleOperaMedia(
|
||||||
|
paramMap: ParamMap,
|
||||||
|
mediaInfoMap: Map<MediaType, MediaInfo>,
|
||||||
|
forceDownload: Boolean
|
||||||
|
) {
|
||||||
|
//messages
|
||||||
|
if (paramMap.containsKey("MESSAGE_ID")) {
|
||||||
|
val id = paramMap["MESSAGE_ID"].toString()
|
||||||
|
val messageId = id.substring(id.lastIndexOf(":") + 1).toLong()
|
||||||
|
val senderId: String = context.database.getConversationMessageFromId(messageId)!!.sender_id!!
|
||||||
|
val author = context.database.getFriendInfo(senderId)!!.usernameForSorting!!
|
||||||
|
downloadOperaMedia(mediaInfoMap, author)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
//private stories
|
||||||
|
val playlistV2Group =
|
||||||
|
if (paramMap.containsKey("PLAYLIST_V2_GROUP")) paramMap["PLAYLIST_V2_GROUP"].toString() else null
|
||||||
|
if (playlistV2Group != null &&
|
||||||
|
playlistV2Group.contains("storyUserId=") &&
|
||||||
|
(forceDownload || context.config.bool(ConfigProperty.DOWNLOAD_STORIES))
|
||||||
|
) {
|
||||||
|
val storyIdStartIndex = playlistV2Group.indexOf("storyUserId=") + 12
|
||||||
|
val storyUserId = playlistV2Group.substring(storyIdStartIndex, playlistV2Group.indexOf(",", storyIdStartIndex))
|
||||||
|
val author = context.database.getFriendInfo(storyUserId)
|
||||||
|
downloadOperaMedia(mediaInfoMap, author!!.usernameForSorting!!)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val snapSource = paramMap["SNAP_SOURCE"].toString()
|
||||||
|
|
||||||
|
//public stories
|
||||||
|
if (snapSource == "PUBLIC_USER" && (forceDownload || context.config.bool(ConfigProperty.DOWNLOAD_PUBLIC_STORIES))) {
|
||||||
|
val userDisplayName = (if (paramMap.containsKey("USER_DISPLAY_NAME")) paramMap["USER_DISPLAY_NAME"].toString() else "").replace(
|
||||||
|
"[^\\x00-\\x7F]".toRegex(),
|
||||||
|
"")
|
||||||
|
downloadOperaMedia(mediaInfoMap, "Public-Stories/$userDisplayName")
|
||||||
|
}
|
||||||
|
|
||||||
|
//spotlight
|
||||||
|
if (snapSource == "SINGLE_SNAP_STORY" && (forceDownload || context.config.bool(ConfigProperty.DOWNLOAD_SPOTLIGHT))) {
|
||||||
|
downloadOperaMedia(mediaInfoMap, "Spotlight")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun asyncOnActivityCreate() {
|
||||||
|
val operaViewerControllerClass: Class<*> = context.mappings.getMappedClass("OperaPageViewController", "Class")
|
||||||
|
|
||||||
|
val onOperaViewStateCallback: (HookAdapter) -> Unit = onOperaViewStateCallback@{ param ->
|
||||||
|
val viewState = (param.thisObject() as Any).getObjectField(context.mappings.getMappedValue("OperaPageViewController", "viewStateField")).toString()
|
||||||
|
if (viewState != "FULLY_DISPLAYED") {
|
||||||
|
return@onOperaViewStateCallback
|
||||||
|
}
|
||||||
|
val operaLayerList = (param.thisObject() as Any).getObjectField(context.mappings.getMappedValue("OperaPageViewController", "layerListField")) 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
|
||||||
|
|
||||||
|
val mediaInfoMap = mutableMapOf<MediaType, MediaInfo>()
|
||||||
|
val isVideo = mediaParamMap.containsKey("video_media_info_list")
|
||||||
|
mediaInfoMap[MediaType.ORIGINAL] = MediaInfo(
|
||||||
|
(if (isVideo) mediaParamMap["video_media_info_list"] else mediaParamMap["image_media_info"])!!
|
||||||
|
)
|
||||||
|
if (canMergeOverlay() && mediaParamMap.containsKey("overlay_image_media_info")) {
|
||||||
|
mediaInfoMap[MediaType.OVERLAY] =
|
||||||
|
MediaInfo(mediaParamMap["overlay_image_media_info"]!!)
|
||||||
|
}
|
||||||
|
lastSeenMapParams = mediaParamMap
|
||||||
|
lastSeenMediaInfoMap = mediaInfoMap
|
||||||
|
if (!context.config.bool(ConfigProperty.MEDIA_DOWNLOADER_FEATURE)) return@onOperaViewStateCallback
|
||||||
|
|
||||||
|
context.executeAsync {
|
||||||
|
try {
|
||||||
|
handleOperaMedia(mediaParamMap, mediaInfoMap, false)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.xposedLog(e)
|
||||||
|
context.longToast(e.message!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
arrayOf("onDisplayStateChange", "onDisplayStateChange2").forEach { methodName ->
|
||||||
|
Hooker.hook(
|
||||||
|
operaViewerControllerClass,
|
||||||
|
context.mappings.getMappedValue("OperaPageViewController", methodName),
|
||||||
|
HookStage.AFTER, onOperaViewStateCallback
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a message is focused in chat
|
||||||
|
*/
|
||||||
|
//TODO: use snapchat classes instead of database (when content is deleted)
|
||||||
|
fun onMessageActionMenu(isPreviewMode: Boolean) {
|
||||||
|
//check if the message was focused in a conversation
|
||||||
|
val messaging = context.feature(Messaging::class)
|
||||||
|
if (messaging.lastOpenedConversationUUID == null) return
|
||||||
|
val message = context.database.getConversationMessageFromId(messaging.lastFocusedMessageId) ?: return
|
||||||
|
|
||||||
|
//get the message author
|
||||||
|
val messageAuthor: String = context.database.getFriendInfo(message.sender_id!!)!!.usernameForSorting!!
|
||||||
|
|
||||||
|
//check if the messageId
|
||||||
|
val contentType: ContentType = ContentType.fromId(message.content_type)
|
||||||
|
if (context.feature(MessageLogger::class).isMessageRemoved(message.client_message_id.toLong())) {
|
||||||
|
context.shortToast("Preview/Download are not yet available for deleted messages")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (contentType != ContentType.NOTE &&
|
||||||
|
contentType != ContentType.SNAP &&
|
||||||
|
contentType != ContentType.EXTERNAL_MEDIA) {
|
||||||
|
context.shortToast("Unsupported content type $contentType")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val messageReader = ProtoReader(message.message_content!!)
|
||||||
|
val urlKey: String = messageReader.getString(*ARROYO_URL_KEY_PROTO_PATH)!!
|
||||||
|
|
||||||
|
//download the message content
|
||||||
|
try {
|
||||||
|
context.shortToast("Retriving message media")
|
||||||
|
var inputStream: InputStream = CdnDownloader.downloadWithDefaultEndpoints(urlKey) ?: return
|
||||||
|
inputStream = EncryptionUtils.decryptInputStreamFromArroyo(
|
||||||
|
inputStream,
|
||||||
|
contentType,
|
||||||
|
messageReader
|
||||||
|
)
|
||||||
|
|
||||||
|
var mediaData: ByteArray = inputStream.readBytes()
|
||||||
|
var fileType = FileType.fromByteArray(mediaData)
|
||||||
|
val isZipFile = fileType == FileType.ZIP
|
||||||
|
|
||||||
|
//videos with overlay are packed in a zip file
|
||||||
|
//there are 2 files in the zip file, the video (webm) and the overlay (png)
|
||||||
|
if (isZipFile) {
|
||||||
|
var videoData: ByteArray? = null
|
||||||
|
var overlayData: ByteArray? = null
|
||||||
|
val zipInputStream = ZipInputStream(ByteArrayInputStream(mediaData))
|
||||||
|
while (zipInputStream.nextEntry != null) {
|
||||||
|
val zipEntryData: ByteArray = zipInputStream.readBytes()
|
||||||
|
val entryFileType = FileType.fromByteArray(zipEntryData)
|
||||||
|
if (entryFileType.isVideo) {
|
||||||
|
videoData = zipEntryData
|
||||||
|
} else if (entryFileType.isImage) {
|
||||||
|
overlayData = zipEntryData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (videoData == null || overlayData == null) {
|
||||||
|
Logger.xposedLog("Invalid data in zip file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val mergedVideo = mergeOverlay(videoData, overlayData, isPreviewMode)
|
||||||
|
val videoFileType = FileType.fromByteArray(videoData)
|
||||||
|
if (!isPreviewMode) {
|
||||||
|
downloadMediaContent(
|
||||||
|
mergedVideo!!,
|
||||||
|
Arrays.hashCode(videoData),
|
||||||
|
messageAuthor,
|
||||||
|
videoFileType
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mediaData = mergedVideo!!
|
||||||
|
fileType = videoFileType
|
||||||
|
}
|
||||||
|
if (isPreviewMode) {
|
||||||
|
runCatching {
|
||||||
|
val bitmap: Bitmap = PreviewUtils.createPreview(mediaData, fileType.isVideo)!!
|
||||||
|
val builder = AlertDialog.Builder(context.mainActivity)
|
||||||
|
builder.setTitle("Preview")
|
||||||
|
val imageView = ImageView(builder.context)
|
||||||
|
imageView.setImageBitmap(bitmap)
|
||||||
|
builder.setView(imageView)
|
||||||
|
builder.setPositiveButton(
|
||||||
|
"Close"
|
||||||
|
) { dialog: DialogInterface, _: Int -> dialog.dismiss() }
|
||||||
|
context.runOnUiThread { builder.show() }
|
||||||
|
}.onFailure {
|
||||||
|
context.shortToast("Failed to create preview: ${it.message}")
|
||||||
|
xposedLog(it)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
downloadMediaContent(mediaData, mediaData.contentHashCode(), messageAuthor, fileType)
|
||||||
|
} catch (e: FileNotFoundException) {
|
||||||
|
context.shortToast("Unable to get $urlKey from cdn list. Check the logs for more info")
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
context.shortToast("Failed to download " + e.message)
|
||||||
|
xposedLog(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,133 @@
|
|||||||
|
package me.rhunk.snapenhance.features.impl.extras
|
||||||
|
|
||||||
|
import me.rhunk.snapenhance.Logger
|
||||||
|
import me.rhunk.snapenhance.config.ConfigProperty
|
||||||
|
import me.rhunk.snapenhance.data.ContentType
|
||||||
|
import me.rhunk.snapenhance.data.wrapper.impl.Message
|
||||||
|
import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID
|
||||||
|
import me.rhunk.snapenhance.features.Feature
|
||||||
|
import me.rhunk.snapenhance.features.FeatureLoadParams
|
||||||
|
import me.rhunk.snapenhance.features.impl.Messaging
|
||||||
|
import me.rhunk.snapenhance.features.impl.spy.MessageLogger
|
||||||
|
import me.rhunk.snapenhance.features.impl.spy.StealthMode
|
||||||
|
import me.rhunk.snapenhance.hook.HookStage
|
||||||
|
import me.rhunk.snapenhance.hook.Hooker
|
||||||
|
import me.rhunk.snapenhance.util.CallbackBuilder
|
||||||
|
import me.rhunk.snapenhance.util.getObjectField
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
|
class AutoSave : Feature("Auto Save", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) {
|
||||||
|
private val asyncSaveExecutorService = Executors.newSingleThreadExecutor()
|
||||||
|
|
||||||
|
private val messageLogger by lazy { context.feature(MessageLogger::class) }
|
||||||
|
private val messaging by lazy { context.feature(Messaging::class) }
|
||||||
|
|
||||||
|
private val myUserId by lazy { context.database.getMyUserId() }
|
||||||
|
|
||||||
|
private val fetchConversationWithMessagesCallbackClass by lazy { context.mappings.getMappedClass("callbacks", "FetchConversationWithMessagesCallback") }
|
||||||
|
private val callbackClass by lazy { context.mappings.getMappedClass("callbacks", "Callback") }
|
||||||
|
|
||||||
|
private val updateMessageMethod by lazy { context.classCache.conversationManager.methods.first { it.name == "updateMessage" } }
|
||||||
|
private val fetchConversationWithMessagesPaginatedMethod by lazy {
|
||||||
|
context.classCache.conversationManager.methods.first { it.name == "fetchConversationWithMessagesPaginated" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveMessage(conversationId: SnapUUID, message: Message) {
|
||||||
|
val messageId = message.messageDescriptor.messageId
|
||||||
|
if (messageLogger.isMessageRemoved(messageId)) return
|
||||||
|
|
||||||
|
val callback = CallbackBuilder(callbackClass)
|
||||||
|
.override("onError") {
|
||||||
|
Logger.xposedLog("Error saving message $messageId")
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
updateMessageMethod.invoke(
|
||||||
|
context.feature(Messaging::class).conversationManager,
|
||||||
|
conversationId.instance(),
|
||||||
|
messageId,
|
||||||
|
context.classCache.messageUpdateEnum.enumConstants.first { it.toString() == "SAVE" },
|
||||||
|
callback
|
||||||
|
)
|
||||||
|
}.onFailure {
|
||||||
|
Logger.xposedLog("Error saving message $messageId", it)
|
||||||
|
}
|
||||||
|
|
||||||
|
//delay between saves
|
||||||
|
Thread.sleep(100L)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun canSaveMessage(message: Message): Boolean {
|
||||||
|
if (message.messageMetadata.savedBy.any { uuid -> uuid.toString() == myUserId }) return false
|
||||||
|
//only save chats
|
||||||
|
with(message.messageContent.contentType) {
|
||||||
|
if (this != ContentType.CHAT &&
|
||||||
|
this != ContentType.NOTE &&
|
||||||
|
this != ContentType.STICKER &&
|
||||||
|
this != ContentType.EXTERNAL_MEDIA) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun canSave(): Boolean {
|
||||||
|
with(context.feature(Messaging::class)) {
|
||||||
|
if (lastOpenedConversationUUID == null || context.feature(StealthMode::class).isStealth(lastOpenedConversationUUID.toString())) return@canSave false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun asyncOnActivityCreate() {
|
||||||
|
//called when enter in a conversation (or when a message is sent)
|
||||||
|
Hooker.hook(
|
||||||
|
context.mappings.getMappedClass("callbacks", "FetchConversationWithMessagesCallback"),
|
||||||
|
"onFetchConversationWithMessagesComplete",
|
||||||
|
HookStage.BEFORE,
|
||||||
|
{ context.config.bool(ConfigProperty.AUTO_SAVE) && canSave()}
|
||||||
|
) { param ->
|
||||||
|
val conversationId = SnapUUID(param.arg<Any>(0).getObjectField("mConversationId"))
|
||||||
|
val messages = param.arg<List<Any>>(1).map { Message(it) }
|
||||||
|
messages.forEach {
|
||||||
|
if (!canSaveMessage(it)) return@forEach
|
||||||
|
asyncSaveExecutorService.submit {
|
||||||
|
saveMessage(conversationId, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//called when a message is received
|
||||||
|
Hooker.hook(
|
||||||
|
context.mappings.getMappedClass("callbacks", "FetchMessageCallback"),
|
||||||
|
"onFetchMessageComplete",
|
||||||
|
HookStage.BEFORE,
|
||||||
|
{ context.config.bool(ConfigProperty.AUTO_SAVE) && canSave()}
|
||||||
|
) { param ->
|
||||||
|
val message = Message(param.arg(0))
|
||||||
|
if (!canSaveMessage(message)) return@hook
|
||||||
|
val conversationId = message.messageDescriptor.conversationId
|
||||||
|
|
||||||
|
asyncSaveExecutorService.submit {
|
||||||
|
saveMessage(conversationId, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Hooker.hook(
|
||||||
|
context.mappings.getMappedClass("callbacks", "SendMessageCallback"),
|
||||||
|
"onSuccess",
|
||||||
|
HookStage.BEFORE,
|
||||||
|
{ context.config.bool(ConfigProperty.AUTO_SAVE) && canSave()}
|
||||||
|
) {
|
||||||
|
val callback = CallbackBuilder(fetchConversationWithMessagesCallbackClass).build()
|
||||||
|
runCatching {
|
||||||
|
fetchConversationWithMessagesPaginatedMethod.invoke(
|
||||||
|
messaging.conversationManager, messaging.lastOpenedConversationUUID!!.instance(),
|
||||||
|
Long.MAX_VALUE,
|
||||||
|
3,
|
||||||
|
callback
|
||||||
|
)
|
||||||
|
}.onFailure {
|
||||||
|
Logger.xposedLog("failed to save message", it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,183 @@
|
|||||||
|
package me.rhunk.snapenhance.features.impl.extras
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.UserHandle
|
||||||
|
import de.robv.android.xposed.XposedBridge
|
||||||
|
import de.robv.android.xposed.XposedHelpers
|
||||||
|
import me.rhunk.snapenhance.Constants
|
||||||
|
import me.rhunk.snapenhance.Logger
|
||||||
|
import me.rhunk.snapenhance.config.ConfigProperty
|
||||||
|
import me.rhunk.snapenhance.data.ContentType
|
||||||
|
import me.rhunk.snapenhance.data.MediaReferenceType
|
||||||
|
import me.rhunk.snapenhance.data.wrapper.impl.Message
|
||||||
|
import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID
|
||||||
|
import me.rhunk.snapenhance.features.Feature
|
||||||
|
import me.rhunk.snapenhance.features.FeatureLoadParams
|
||||||
|
import me.rhunk.snapenhance.features.impl.Messaging
|
||||||
|
import me.rhunk.snapenhance.hook.HookStage
|
||||||
|
import me.rhunk.snapenhance.hook.Hooker
|
||||||
|
import me.rhunk.snapenhance.util.CallbackBuilder
|
||||||
|
import me.rhunk.snapenhance.util.EncryptionUtils
|
||||||
|
import me.rhunk.snapenhance.util.PreviewUtils
|
||||||
|
import me.rhunk.snapenhance.util.download.CdnDownloader
|
||||||
|
import me.rhunk.snapenhance.util.protobuf.ProtoReader
|
||||||
|
|
||||||
|
class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.INIT_SYNC) {
|
||||||
|
private val notificationDataQueue = mutableMapOf<Long, NotificationData>()
|
||||||
|
private val cachedNotifications = mutableMapOf<String, MutableList<String>>()
|
||||||
|
|
||||||
|
private val notifyAsUserMethod by lazy {
|
||||||
|
XposedHelpers.findMethodExact(
|
||||||
|
NotificationManager::class.java, "notifyAsUser",
|
||||||
|
String::class.java,
|
||||||
|
Int::class.javaPrimitiveType,
|
||||||
|
Notification::class.java,
|
||||||
|
UserHandle::class.java
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val fetchConversationWithMessagesMethod by lazy {
|
||||||
|
context.classCache.conversationManager.methods.first { it.name == "fetchConversationWithMessages"}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val notificationManager by lazy {
|
||||||
|
context.androidContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setNotificationText(notification: NotificationData, text: String) {
|
||||||
|
with(notification.notification.extras) {
|
||||||
|
putString("android.text", text)
|
||||||
|
putString("android.bigText", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun computeNotificationText(conversationId: String): String {
|
||||||
|
val messageBuilder = StringBuilder()
|
||||||
|
cachedNotifications.computeIfAbsent(conversationId) { mutableListOf() }.forEach {
|
||||||
|
if (messageBuilder.isNotEmpty()) messageBuilder.append("\n")
|
||||||
|
messageBuilder.append(it)
|
||||||
|
}
|
||||||
|
return messageBuilder.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchMessagesResult(conversationId: String, messages: List<Message>) {
|
||||||
|
val sendNotificationData = { it: NotificationData ->
|
||||||
|
XposedBridge.invokeOriginalMethod(notifyAsUserMethod, notificationManager, arrayOf(
|
||||||
|
it.tag, it.id, it.notification, it.userHandle
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
notificationDataQueue.entries.onEach { (messageId, notificationData) ->
|
||||||
|
val snapMessage = messages.firstOrNull { message -> message.orderKey == messageId } ?: return
|
||||||
|
val senderUsername = context.database.getFriendInfo(snapMessage.senderId.toString())?.displayName ?: throw Throwable("Cant find senderId of message $snapMessage")
|
||||||
|
|
||||||
|
val contentType = snapMessage.messageContent.contentType
|
||||||
|
val contentData = snapMessage.messageContent.content
|
||||||
|
|
||||||
|
val formatUsername: (String) -> String = { "$senderUsername: $it" }
|
||||||
|
val notificationCache = cachedNotifications.let { it.computeIfAbsent(conversationId) { mutableListOf() } }
|
||||||
|
val appendNotifications: () -> Unit = { setNotificationText(notificationData, computeNotificationText(conversationId))}
|
||||||
|
|
||||||
|
when (contentType) {
|
||||||
|
ContentType.NOTE -> {
|
||||||
|
notificationCache.add(formatUsername("sent audio note"))
|
||||||
|
appendNotifications()
|
||||||
|
}
|
||||||
|
ContentType.CHAT -> {
|
||||||
|
ProtoReader(contentData).getString(2, 1)?.trim()?.let {
|
||||||
|
notificationCache.add(formatUsername(it))
|
||||||
|
}
|
||||||
|
appendNotifications()
|
||||||
|
}
|
||||||
|
ContentType.SNAP -> {
|
||||||
|
//serialize the message content into a json object
|
||||||
|
val serializedMessageContent = context.gson.toJsonTree(snapMessage.messageContent.instance()).asJsonObject
|
||||||
|
val mediaReferences = serializedMessageContent["mRemoteMediaReferences"]
|
||||||
|
.asJsonArray.map { it.asJsonObject["mMediaReferences"].asJsonArray }
|
||||||
|
.flatten()
|
||||||
|
|
||||||
|
mediaReferences.forEach { media ->
|
||||||
|
val mediaContent = media.asJsonObject["mContentObject"].asJsonArray.map { it.asByte }.toByteArray()
|
||||||
|
val mediaType = MediaReferenceType.valueOf(media.asJsonObject["mMediaType"].asString)
|
||||||
|
val urlKey = ProtoReader(mediaContent).getString(2, 2) ?: return@forEach
|
||||||
|
runCatching {
|
||||||
|
//download the media
|
||||||
|
var mediaInputStream = CdnDownloader.downloadWithDefaultEndpoints(urlKey)!!
|
||||||
|
val mediaInfo = ProtoReader(contentData).readPath(*Constants.MESSAGE_SNAP_ENCRYPTION_PROTO_PATH) ?: return@runCatching
|
||||||
|
//decrypt if necessary
|
||||||
|
if (mediaInfo.exists(Constants.ARROYO_ENCRYPTION_PROTO_INDEX)) {
|
||||||
|
mediaInputStream = EncryptionUtils.decryptInputStream(mediaInputStream, false, mediaInfo, Constants.ARROYO_ENCRYPTION_PROTO_INDEX)
|
||||||
|
}
|
||||||
|
|
||||||
|
val mediaByteArray = mediaInputStream.readBytes()
|
||||||
|
val bitmapPreview = PreviewUtils.createPreview(mediaByteArray, mediaType == MediaReferenceType.VIDEO)!!
|
||||||
|
|
||||||
|
val notificationBuilder = XposedHelpers.newInstance(
|
||||||
|
Notification.Builder::class.java,
|
||||||
|
context.androidContext,
|
||||||
|
notificationData.notification
|
||||||
|
) as Notification.Builder
|
||||||
|
notificationBuilder.setLargeIcon(bitmapPreview)
|
||||||
|
notificationBuilder.style = Notification.BigPictureStyle().bigPicture(bitmapPreview).bigLargeIcon(null as Bitmap?)
|
||||||
|
|
||||||
|
sendNotificationData(notificationData.copy(id = System.nanoTime().toInt(), notification = notificationBuilder.build()))
|
||||||
|
return@onEach
|
||||||
|
}.onFailure {
|
||||||
|
Logger.error("Failed to send preview notification", it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
notificationCache.add(formatUsername("sent $contentType"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendNotificationData(notificationData)
|
||||||
|
}.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun init() {
|
||||||
|
val fetchConversationWithMessagesCallback = context.mappings.getMappedClass("callbacks", "FetchConversationWithMessagesCallback")
|
||||||
|
|
||||||
|
Hooker.hook(notifyAsUserMethod, HookStage.BEFORE, { context.config.bool(ConfigProperty.SHOW_MESSAGE_CONTENT) }) {
|
||||||
|
val notificationData = NotificationData(it.argNullable(0), it.arg(1), it.arg(2), it.arg(3))
|
||||||
|
|
||||||
|
if (!notificationData.notification.extras.containsKey("system_notification_extras")) {
|
||||||
|
return@hook
|
||||||
|
}
|
||||||
|
val extras: Bundle = notificationData.notification.extras.getBundle("system_notification_extras")!!
|
||||||
|
|
||||||
|
val messageId = extras.getString("message_id")!!
|
||||||
|
val notificationType = extras.getString("notification_type")!!
|
||||||
|
val conversationId = extras.getString("conversation_id")!!
|
||||||
|
|
||||||
|
if (!notificationType.endsWith("CHAT") && !notificationType.endsWith("SNAP")) return@hook
|
||||||
|
|
||||||
|
val conversationManager: Any = context.feature(Messaging::class).conversationManager
|
||||||
|
notificationDataQueue[messageId.toLong()] = notificationData
|
||||||
|
|
||||||
|
val callback = CallbackBuilder(fetchConversationWithMessagesCallback)
|
||||||
|
.override("onFetchConversationWithMessagesComplete") { param ->
|
||||||
|
val messageList = (param.arg(1) as List<Any>).map { msg -> Message(msg) }
|
||||||
|
fetchMessagesResult(conversationId, messageList)
|
||||||
|
}
|
||||||
|
.override("onError") { param ->
|
||||||
|
Logger.xposedLog("Failed to fetch message ${param.arg(0) as Any}")
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
fetchConversationWithMessagesMethod.invoke(conversationManager, SnapUUID.fromString(conversationId).instance(), callback)
|
||||||
|
it.setResult(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class NotificationData(
|
||||||
|
val tag: String?,
|
||||||
|
val id: Int,
|
||||||
|
var notification: Notification,
|
||||||
|
val userHandle: UserHandle
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,28 @@
|
|||||||
|
package me.rhunk.snapenhance.features.impl.extras
|
||||||
|
|
||||||
|
import me.rhunk.snapenhance.config.ConfigProperty
|
||||||
|
import me.rhunk.snapenhance.features.Feature
|
||||||
|
import me.rhunk.snapenhance.features.FeatureLoadParams
|
||||||
|
import me.rhunk.snapenhance.hook.HookStage
|
||||||
|
import me.rhunk.snapenhance.hook.Hooker
|
||||||
|
|
||||||
|
class SnapchatPlus: Feature("SnapchatPlus", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) {
|
||||||
|
override fun asyncOnActivityCreate() {
|
||||||
|
if (!context.config.bool(ConfigProperty.SNAPCHAT_PLUS)) return
|
||||||
|
|
||||||
|
Hooker.hookConstructor(context.mappings.getMappedClass("SubscriptionInfoClass"), HookStage.BEFORE) { param ->
|
||||||
|
//check if the user is already premium
|
||||||
|
if (param.arg(0) as Int == 2) {
|
||||||
|
return@hookConstructor
|
||||||
|
}
|
||||||
|
//subscription info tier
|
||||||
|
param.setArg(0, 2)
|
||||||
|
//subscription status
|
||||||
|
param.setArg(1, 2)
|
||||||
|
//subscription time
|
||||||
|
param.setArg(2, System.currentTimeMillis() - 7776000000L)
|
||||||
|
//expiration time
|
||||||
|
param.setArg(3, System.currentTimeMillis() + 15552000000L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,45 @@
|
|||||||
|
package me.rhunk.snapenhance.features.impl.privacy
|
||||||
|
|
||||||
|
import de.robv.android.xposed.XposedHelpers
|
||||||
|
import me.rhunk.snapenhance.Logger.debug
|
||||||
|
import me.rhunk.snapenhance.config.ConfigProperty
|
||||||
|
import me.rhunk.snapenhance.features.Feature
|
||||||
|
import me.rhunk.snapenhance.features.FeatureLoadParams
|
||||||
|
import me.rhunk.snapenhance.hook.HookAdapter
|
||||||
|
import me.rhunk.snapenhance.hook.HookStage
|
||||||
|
import me.rhunk.snapenhance.hook.Hooker
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
class DisableMetrics : Feature("DisableMetrics", loadParams = FeatureLoadParams.INIT_SYNC) {
|
||||||
|
override fun init() {
|
||||||
|
val disableMetricsFilter: (HookAdapter) -> Boolean = {
|
||||||
|
context.config.bool(ConfigProperty.DISABLE_METRICS)
|
||||||
|
}
|
||||||
|
|
||||||
|
Hooker.hook(context.classCache.unifiedGrpcService, "unaryCall", HookStage.BEFORE, disableMetricsFilter) { param ->
|
||||||
|
val url: String = param.arg(0)
|
||||||
|
if (url.endsWith("snapchat.valis.Valis/SendClientUpdate") ||
|
||||||
|
url.endsWith("targetingQuery")
|
||||||
|
) {
|
||||||
|
param.setResult(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Hooker.hook(context.classCache.networkApi, "submit", HookStage.BEFORE, disableMetricsFilter) { param ->
|
||||||
|
val httpRequest: Any = param.arg(0)
|
||||||
|
val url = XposedHelpers.getObjectField(httpRequest, "mUrl").toString()
|
||||||
|
if (url.contains("resolve?co=")) {
|
||||||
|
val index = url.indexOf("co=")
|
||||||
|
val end = url.lastIndexOf("&")
|
||||||
|
val co = url.substring(index + 3, end)
|
||||||
|
val decoded = Base64.getDecoder().decode(co.toByteArray(StandardCharsets.UTF_8))
|
||||||
|
debug("decoded : " + decoded.toString(Charsets.UTF_8))
|
||||||
|
debug("content: $co")
|
||||||
|
}
|
||||||
|
if (url.contains("app-analytics") || url.endsWith("v1/metrics")) {
|
||||||
|
param.setResult(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,16 @@
|
|||||||
|
package me.rhunk.snapenhance.features.impl.privacy
|
||||||
|
|
||||||
|
import android.database.ContentObserver
|
||||||
|
import me.rhunk.snapenhance.config.ConfigProperty
|
||||||
|
import me.rhunk.snapenhance.features.Feature
|
||||||
|
import me.rhunk.snapenhance.features.FeatureLoadParams
|
||||||
|
import me.rhunk.snapenhance.hook.HookStage
|
||||||
|
import me.rhunk.snapenhance.hook.Hooker
|
||||||
|
|
||||||
|
class PreventScreenshotDetections : Feature("Prevent Screenshot Detections", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) {
|
||||||
|
override fun asyncOnActivityCreate() {
|
||||||
|
Hooker.hook(ContentObserver::class.java,"dispatchChange", HookStage.BEFORE, { context.config.bool(ConfigProperty.PREVENT_SCREENSHOTS) }) {
|
||||||
|
it.setResult(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,20 @@
|
|||||||
|
package me.rhunk.snapenhance.features.impl.spy
|
||||||
|
|
||||||
|
import me.rhunk.snapenhance.config.ConfigProperty
|
||||||
|
import me.rhunk.snapenhance.features.Feature
|
||||||
|
import me.rhunk.snapenhance.features.FeatureLoadParams
|
||||||
|
import me.rhunk.snapenhance.hook.HookStage
|
||||||
|
import me.rhunk.snapenhance.hook.Hooker
|
||||||
|
import me.rhunk.snapenhance.util.getObjectField
|
||||||
|
|
||||||
|
class AnonymousStoryViewing : Feature("Anonymous Story Viewing", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) {
|
||||||
|
override fun asyncOnActivityCreate() {
|
||||||
|
Hooker.hook(context.classCache.networkApi,"submit", HookStage.BEFORE, { context.config.bool(ConfigProperty.ANONYMOUS_STORY_VIEW) }) {
|
||||||
|
val httpRequest: Any = it.arg(0)
|
||||||
|
val url = httpRequest.getObjectField("mUrl") as String
|
||||||
|
if (url.endsWith("readreceipt-indexer/batchuploadreadreceipts") || url.endsWith("v2/batch_cta")) {
|
||||||
|
it.setResult(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,64 @@
|
|||||||
|
package me.rhunk.snapenhance.features.impl.spy
|
||||||
|
|
||||||
|
import com.google.gson.JsonParser
|
||||||
|
import me.rhunk.snapenhance.config.ConfigProperty
|
||||||
|
import me.rhunk.snapenhance.data.ContentType
|
||||||
|
import me.rhunk.snapenhance.data.MessageState
|
||||||
|
import me.rhunk.snapenhance.features.Feature
|
||||||
|
import me.rhunk.snapenhance.features.FeatureLoadParams
|
||||||
|
import me.rhunk.snapenhance.hook.HookStage
|
||||||
|
import me.rhunk.snapenhance.hook.Hooker
|
||||||
|
import me.rhunk.snapenhance.util.getObjectField
|
||||||
|
|
||||||
|
class MessageLogger : Feature("MessageLogger", loadParams = FeatureLoadParams.INIT_SYNC) {
|
||||||
|
private val messageCache = mutableMapOf<Long, String>()
|
||||||
|
private val removedMessages = linkedSetOf<Long>()
|
||||||
|
|
||||||
|
fun isMessageRemoved(messageId: Long) = removedMessages.contains(messageId)
|
||||||
|
|
||||||
|
override fun init() {
|
||||||
|
Hooker.hookConstructor(context.classCache.message, HookStage.AFTER, {
|
||||||
|
context.config.bool(ConfigProperty.MESSAGE_LOGGER)
|
||||||
|
}) {
|
||||||
|
val message = it.thisObject<Any>()
|
||||||
|
val messageId = message.getObjectField("mDescriptor").getObjectField("mMessageId") as Long
|
||||||
|
val contentType = ContentType.valueOf(message.getObjectField("mMessageContent").getObjectField("mContentType").toString())
|
||||||
|
val messageState = MessageState.valueOf(message.getObjectField("mState").toString())
|
||||||
|
|
||||||
|
if (messageState != MessageState.COMMITTED) return@hookConstructor
|
||||||
|
|
||||||
|
if (contentType == ContentType.STATUS) {
|
||||||
|
//query the deleted message
|
||||||
|
val deletedMessage: String = if (messageCache.containsKey(messageId)) messageCache[messageId] else {
|
||||||
|
context.bridgeClient.getMessageLoggerMessage(messageId)?.toString(Charsets.UTF_8)
|
||||||
|
} ?: return@hookConstructor
|
||||||
|
|
||||||
|
val messageJsonObject = JsonParser.parseString(deletedMessage).asJsonObject
|
||||||
|
|
||||||
|
//if the message is a snap make it playable
|
||||||
|
if (messageJsonObject["mMessageContent"].asJsonObject["mContentType"].asString == "SNAP") {
|
||||||
|
messageJsonObject["mMetadata"].asJsonObject.addProperty("mPlayableSnapState", "PLAYABLE")
|
||||||
|
}
|
||||||
|
|
||||||
|
//serialize all properties of messageJsonObject and put in the message object
|
||||||
|
message.javaClass.declaredFields.forEach { field ->
|
||||||
|
field.isAccessible = true
|
||||||
|
val fieldName = field.name
|
||||||
|
val fieldValue = messageJsonObject[fieldName]
|
||||||
|
if (fieldValue != null) {
|
||||||
|
field.set(message, context.gson.fromJson(fieldValue, field.type))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removedMessages.add(messageId)
|
||||||
|
return@hookConstructor
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!messageCache.containsKey(messageId)) {
|
||||||
|
val serializedMessage = context.gson.toJson(message)
|
||||||
|
messageCache[messageId] = serializedMessage
|
||||||
|
context.bridgeClient.addMessageLoggerMessage(messageId, serializedMessage.toByteArray(Charsets.UTF_8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,28 @@
|
|||||||
|
package me.rhunk.snapenhance.features.impl.spy
|
||||||
|
|
||||||
|
import me.rhunk.snapenhance.config.ConfigProperty
|
||||||
|
import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID
|
||||||
|
import me.rhunk.snapenhance.features.Feature
|
||||||
|
import me.rhunk.snapenhance.features.FeatureLoadParams
|
||||||
|
import me.rhunk.snapenhance.hook.HookStage
|
||||||
|
import me.rhunk.snapenhance.hook.Hooker
|
||||||
|
|
||||||
|
class PreventReadReceipts : Feature("PreventReadReceipts", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
|
||||||
|
override fun onActivityCreate() {
|
||||||
|
val isConversationInStealthMode: (SnapUUID) -> Boolean = hook@{
|
||||||
|
if (context.config.bool(ConfigProperty.PREVENT_READ_RECEIPTS)) return@hook true
|
||||||
|
context.feature(StealthMode::class).isStealth(it.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
arrayOf("mediaMessagesDisplayed", "displayedMessages").forEach { methodName: String ->
|
||||||
|
Hooker.hook(context.classCache.conversationManager, methodName, HookStage.BEFORE, { isConversationInStealthMode(SnapUUID(it.arg(0))) }) {
|
||||||
|
it.setResult(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Hooker.hook(context.classCache.snapManager, "onSnapInteraction", HookStage.BEFORE) {
|
||||||
|
if (isConversationInStealthMode(SnapUUID(it.arg(1) as Any))) {
|
||||||
|
it.setResult(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,28 @@
|
|||||||
|
package me.rhunk.snapenhance.features.impl.spy
|
||||||
|
|
||||||
|
import me.rhunk.snapenhance.config.ConfigProperty
|
||||||
|
import me.rhunk.snapenhance.data.ContentType
|
||||||
|
import me.rhunk.snapenhance.features.Feature
|
||||||
|
import me.rhunk.snapenhance.features.FeatureLoadParams
|
||||||
|
import me.rhunk.snapenhance.hook.HookStage
|
||||||
|
import me.rhunk.snapenhance.hook.Hooker
|
||||||
|
import me.rhunk.snapenhance.util.getObjectField
|
||||||
|
|
||||||
|
|
||||||
|
class PreventStatusNotifications : Feature("PreventStatusNotifications", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) {
|
||||||
|
override fun asyncOnActivityCreate() {
|
||||||
|
Hooker.hook(
|
||||||
|
context.classCache.conversationManager,
|
||||||
|
"sendMessageWithContent",
|
||||||
|
HookStage.BEFORE,
|
||||||
|
{context.config.bool(ConfigProperty.PREVENT_STATUS_NOTIFICATIONS) }) { param ->
|
||||||
|
val contentTypeString = (param.arg(1) as Any).getObjectField("mContentType")
|
||||||
|
|
||||||
|
if (contentTypeString == ContentType.STATUS_SAVE_TO_CAMERA_ROLL.name ||
|
||||||
|
contentTypeString == ContentType.STATUS_CONVERSATION_CAPTURE_SCREENSHOT.name ||
|
||||||
|
contentTypeString == ContentType.STATUS_CONVERSATION_CAPTURE_RECORD.name) {
|
||||||
|
param.setResult(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,62 @@
|
|||||||
|
package me.rhunk.snapenhance.features.impl.spy
|
||||||
|
|
||||||
|
import me.rhunk.snapenhance.bridge.common.impl.FileAccessRequest
|
||||||
|
import me.rhunk.snapenhance.features.Feature
|
||||||
|
import me.rhunk.snapenhance.features.FeatureLoadParams
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
|
||||||
|
|
||||||
|
class StealthMode : Feature("StealthMode", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
|
||||||
|
private val stealthConversations = mutableListOf<String>()
|
||||||
|
|
||||||
|
override fun onActivityCreate() {
|
||||||
|
readStealthFile()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeStealthFile() {
|
||||||
|
val sb = StringBuilder()
|
||||||
|
for (stealthConversation in stealthConversations) {
|
||||||
|
sb.append(stealthConversation).append("\n")
|
||||||
|
}
|
||||||
|
context.bridgeClient.writeFile(
|
||||||
|
FileAccessRequest.FileType.STEALTH,
|
||||||
|
sb.toString().toByteArray(StandardCharsets.UTF_8)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readStealthFile() {
|
||||||
|
val conversations = mutableListOf<String>()
|
||||||
|
val stealthFileData: ByteArray = context.bridgeClient.createAndReadFile(FileAccessRequest.FileType.STEALTH, ByteArray(0))
|
||||||
|
//read conversations
|
||||||
|
with(BufferedReader(InputStreamReader(
|
||||||
|
ByteArrayInputStream(stealthFileData),
|
||||||
|
StandardCharsets.UTF_8
|
||||||
|
))) {
|
||||||
|
var line: String = ""
|
||||||
|
while (readLine()?.also { line = it } != null) {
|
||||||
|
conversations.add(line)
|
||||||
|
}
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
stealthConversations.clear()
|
||||||
|
stealthConversations.addAll(conversations)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setStealth(conversationId: String, stealth: Boolean) {
|
||||||
|
conversationId.hashCode().toLong().toString(16).let {
|
||||||
|
if (stealth) {
|
||||||
|
stealthConversations.add(it)
|
||||||
|
} else {
|
||||||
|
stealthConversations.remove(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeStealthFile()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isStealth(conversationId: String): Boolean {
|
||||||
|
return stealthConversations.contains(conversationId.hashCode().toLong().toString(16))
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,61 @@
|
|||||||
|
package me.rhunk.snapenhance.features.impl.ui
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import me.rhunk.snapenhance.Constants
|
||||||
|
import me.rhunk.snapenhance.config.ConfigProperty
|
||||||
|
import me.rhunk.snapenhance.features.Feature
|
||||||
|
import me.rhunk.snapenhance.features.FeatureLoadParams
|
||||||
|
import me.rhunk.snapenhance.hook.HookStage
|
||||||
|
import me.rhunk.snapenhance.hook.Hooker
|
||||||
|
|
||||||
|
class UITweaks : Feature("UITweaks", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
|
||||||
|
override fun onActivityCreate() {
|
||||||
|
val resources = context.resources
|
||||||
|
|
||||||
|
val callButtonsStub = resources.getIdentifier("call_buttons_stub", "id", Constants.SNAPCHAT_PACKAGE_NAME)
|
||||||
|
val callButton1 = resources.getIdentifier("friend_action_button3", "id", Constants.SNAPCHAT_PACKAGE_NAME)
|
||||||
|
val callButton2 = resources.getIdentifier("friend_action_button4", "id", Constants.SNAPCHAT_PACKAGE_NAME)
|
||||||
|
|
||||||
|
val chatNoteRecordButton = resources.getIdentifier("chat_note_record_button", "id", Constants.SNAPCHAT_PACKAGE_NAME)
|
||||||
|
val chatInputBarSticker = resources.getIdentifier("chat_input_bar_sticker", "id", Constants.SNAPCHAT_PACKAGE_NAME)
|
||||||
|
val chatInputBarCognac = resources.getIdentifier("chat_input_bar_cognac", "id", Constants.SNAPCHAT_PACKAGE_NAME)
|
||||||
|
|
||||||
|
Hooker.hook(View::class.java, "setVisibility", HookStage.BEFORE) { methodParam ->
|
||||||
|
val viewId = (methodParam.thisObject() as View).id
|
||||||
|
if (viewId == chatNoteRecordButton && context.config.bool(ConfigProperty.REMOVE_VOICE_RECORD_BUTTON)) {
|
||||||
|
methodParam.setArg(0, View.GONE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//TODO: use the event bus to dispatch a addView event
|
||||||
|
val addViewMethod = ViewGroup::class.java.getMethod(
|
||||||
|
"addView",
|
||||||
|
View::class.java,
|
||||||
|
Int::class.javaPrimitiveType,
|
||||||
|
ViewGroup.LayoutParams::class.java
|
||||||
|
)
|
||||||
|
Hooker.hook(addViewMethod, HookStage.BEFORE) { param ->
|
||||||
|
val view: View = param.arg(0)
|
||||||
|
val viewId = view.id
|
||||||
|
|
||||||
|
if (chatInputBarCognac == viewId && context.config.bool(ConfigProperty.REMOVE_COGNAC_BUTTON)) {
|
||||||
|
view.visibility = View.GONE
|
||||||
|
}
|
||||||
|
if (chatInputBarSticker == viewId && context.config.bool(ConfigProperty.REMOVE_STICKERS_BUTTON)) {
|
||||||
|
view.visibility = View.GONE
|
||||||
|
}
|
||||||
|
if (context.config.bool(ConfigProperty.REMOVE_CALLBUTTONS)) {
|
||||||
|
if (viewId == callButton1 || viewId == callButton2) {
|
||||||
|
if (view.visibility == View.GONE) return@hook
|
||||||
|
Hooker.ephemeralHookObjectMethod(View::class.java, view, "setVisibility", HookStage.BEFORE) { param ->
|
||||||
|
param.setArg(0, View.GONE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (viewId == callButtonsStub) {
|
||||||
|
param.setResult(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
package me.rhunk.snapenhance.features.impl.ui.menus
|
||||||
|
|
||||||
|
import me.rhunk.snapenhance.ModContext
|
||||||
|
|
||||||
|
abstract class AbstractMenu() {
|
||||||
|
lateinit var context: ModContext
|
||||||
|
|
||||||
|
open fun init() {}
|
||||||
|
}
|
@ -0,0 +1,139 @@
|
|||||||
|
package me.rhunk.snapenhance.features.impl.ui.menus
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import de.robv.android.xposed.XposedBridge
|
||||||
|
import me.rhunk.snapenhance.Constants.VIEW_DRAWER
|
||||||
|
import me.rhunk.snapenhance.Constants.VIEW_INJECTED_CODE
|
||||||
|
import me.rhunk.snapenhance.config.ConfigProperty
|
||||||
|
import me.rhunk.snapenhance.features.Feature
|
||||||
|
import me.rhunk.snapenhance.features.FeatureLoadParams
|
||||||
|
import me.rhunk.snapenhance.features.impl.Messaging
|
||||||
|
import me.rhunk.snapenhance.features.impl.ui.menus.impl.ChatActionMenu
|
||||||
|
import me.rhunk.snapenhance.features.impl.ui.menus.impl.FriendFeedInfoMenu
|
||||||
|
import me.rhunk.snapenhance.features.impl.ui.menus.impl.OperaContextActionMenu
|
||||||
|
import me.rhunk.snapenhance.features.impl.ui.menus.impl.SettingsMenu
|
||||||
|
import me.rhunk.snapenhance.hook.HookStage
|
||||||
|
import me.rhunk.snapenhance.hook.Hooker
|
||||||
|
import java.lang.reflect.Field
|
||||||
|
import java.lang.reflect.Modifier
|
||||||
|
|
||||||
|
class MenuViewInjector : Feature("MenuViewInjector", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) {
|
||||||
|
private val friendFeedInfoMenu = FriendFeedInfoMenu()
|
||||||
|
private val operaContextActionMenu = OperaContextActionMenu()
|
||||||
|
private val chatActionMenu = ChatActionMenu()
|
||||||
|
private val settingMenu = SettingsMenu()
|
||||||
|
|
||||||
|
private fun wasInjectedView(view: View): Boolean {
|
||||||
|
if (view.getTag(VIEW_INJECTED_CODE) != null) return true
|
||||||
|
view.setTag(VIEW_INJECTED_CODE, true)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("ResourceType")
|
||||||
|
override fun asyncOnActivityCreate() {
|
||||||
|
friendFeedInfoMenu.context = context
|
||||||
|
operaContextActionMenu.context = context
|
||||||
|
chatActionMenu.context = context
|
||||||
|
settingMenu.context = context
|
||||||
|
|
||||||
|
val addViewMethod = ViewGroup::class.java.getMethod(
|
||||||
|
"addView",
|
||||||
|
View::class.java,
|
||||||
|
Int::class.javaPrimitiveType,
|
||||||
|
ViewGroup.LayoutParams::class.java
|
||||||
|
)
|
||||||
|
|
||||||
|
//catch the card view instance in the action drawer
|
||||||
|
Hooker.hook(
|
||||||
|
LinearLayout::class.java.getConstructor(
|
||||||
|
Context::class.java,
|
||||||
|
AttributeSet::class.java,
|
||||||
|
Int::class.javaPrimitiveType
|
||||||
|
), HookStage.AFTER
|
||||||
|
) { param ->
|
||||||
|
val viewGroup: LinearLayout = param.thisObject()
|
||||||
|
val attribute: Int = param.arg(2)
|
||||||
|
if (attribute == 0) return@hook
|
||||||
|
val resourceName = viewGroup.resources.getResourceName(attribute)
|
||||||
|
if (!resourceName.endsWith("snapCardContentLayoutStyle")) return@hook
|
||||||
|
viewGroup.setTag(VIEW_DRAWER, Any())
|
||||||
|
}
|
||||||
|
|
||||||
|
Hooker.hook(addViewMethod, HookStage.BEFORE) { param ->
|
||||||
|
val viewGroup: ViewGroup = param.thisObject()
|
||||||
|
val originalAddView: (View) -> Unit = { view: View ->
|
||||||
|
XposedBridge.invokeOriginalMethod(
|
||||||
|
addViewMethod,
|
||||||
|
viewGroup,
|
||||||
|
arrayOf(
|
||||||
|
view,
|
||||||
|
-1,
|
||||||
|
FrameLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val childView: View = param.arg(0)
|
||||||
|
operaContextActionMenu.inject(viewGroup, childView)
|
||||||
|
|
||||||
|
//download in chat snaps and notes from the chat action menu
|
||||||
|
if (viewGroup.javaClass.name.endsWith("ActionMenuChatItemContainer")) {
|
||||||
|
if (viewGroup.parent == null || viewGroup.parent
|
||||||
|
.parent == null
|
||||||
|
) return@hook
|
||||||
|
chatActionMenu.inject(viewGroup)
|
||||||
|
return@hook
|
||||||
|
}
|
||||||
|
|
||||||
|
//TODO : preview group chats
|
||||||
|
if (viewGroup !is LinearLayout) return@hook
|
||||||
|
if (viewGroup.getTag(VIEW_DRAWER) == null) return@hook
|
||||||
|
val itemStringInterface =childView.javaClass.declaredFields.filter { field: Field ->
|
||||||
|
!field.type.isPrimitive && Modifier.isAbstract(
|
||||||
|
field.type.modifiers
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.map { field: Field ->
|
||||||
|
try {
|
||||||
|
field.isAccessible = true
|
||||||
|
return@map field[childView]
|
||||||
|
} catch (e: IllegalAccessException) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
null
|
||||||
|
}.firstOrNull()
|
||||||
|
|
||||||
|
//the 3 dot button shows a menu which contains the first item as a Plain object
|
||||||
|
//FIXME: better way to detect the 3 dot button
|
||||||
|
if (viewGroup.getChildCount() == 0 && itemStringInterface != null && itemStringInterface.toString().startsWith("Plain(primaryText=")) {
|
||||||
|
if (wasInjectedView(viewGroup)) return@hook
|
||||||
|
|
||||||
|
settingMenu.inject(viewGroup, originalAddView)
|
||||||
|
viewGroup.addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener {
|
||||||
|
override fun onViewAttachedToWindow(v: View?) {}
|
||||||
|
override fun onViewDetachedFromWindow(v: View?) {
|
||||||
|
context.config.writeConfig()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return@hook
|
||||||
|
}
|
||||||
|
if (context.feature(Messaging::class).lastFetchConversationUserUUID == null) return@hook
|
||||||
|
|
||||||
|
//filter by the slot index
|
||||||
|
if (viewGroup.getChildCount() != context.config.int(ConfigProperty.MENU_SLOT_ID)) return@hook
|
||||||
|
|
||||||
|
friendFeedInfoMenu.inject(viewGroup, originalAddView)
|
||||||
|
childView.setTag(VIEW_DRAWER, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,53 @@
|
|||||||
|
package me.rhunk.snapenhance.features.impl.ui.menus
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.res.ColorStateList
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.Switch
|
||||||
|
import android.widget.TextView
|
||||||
|
|
||||||
|
object ViewAppearanceHelper {
|
||||||
|
fun applyIndentation(view: TextView) {
|
||||||
|
view.setPadding(70, 0, 55, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("UseSwitchCompatOrMaterialCode", "RtlHardcoded")
|
||||||
|
fun applyTheme(viewModel: View, view: TextView) {
|
||||||
|
//remove the shadow
|
||||||
|
view.setBackgroundColor(0x00000000)
|
||||||
|
view.setTextColor(Color.parseColor("#000000"))
|
||||||
|
view.setShadowLayer(0f, 0f, 0f, 0)
|
||||||
|
view.outlineProvider = null
|
||||||
|
view.gravity = Gravity.LEFT or Gravity.CENTER_VERTICAL
|
||||||
|
view.width = viewModel.width
|
||||||
|
//FIXME: hardcoded dimensions
|
||||||
|
view.height = 160
|
||||||
|
view.setPadding(35, 0, 55, 0)
|
||||||
|
view.isAllCaps = false
|
||||||
|
view.textSize = 15f
|
||||||
|
view.typeface = Typeface.DEFAULT
|
||||||
|
|
||||||
|
//remove click effect
|
||||||
|
if (view.javaClass == TextView::class.java) {
|
||||||
|
view.setBackgroundColor(0)
|
||||||
|
}
|
||||||
|
if (view is Switch) {
|
||||||
|
//set the switch color to blue
|
||||||
|
val colorStateList = ColorStateList(
|
||||||
|
arrayOf(
|
||||||
|
intArrayOf(-android.R.attr.state_checked), intArrayOf(
|
||||||
|
android.R.attr.state_checked
|
||||||
|
)
|
||||||
|
), intArrayOf(
|
||||||
|
Color.parseColor("#000000"),
|
||||||
|
Color.parseColor("#2196F3")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
view.trackTintList = colorStateList
|
||||||
|
view.thumbTintList = colorStateList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,91 @@
|
|||||||
|
package me.rhunk.snapenhance.features.impl.ui.menus.impl
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.res.Resources
|
||||||
|
import android.graphics.BlendMode
|
||||||
|
import android.graphics.BlendModeColorFilter
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.util.TypedValue
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.ViewGroup.MarginLayoutParams
|
||||||
|
import android.widget.Button
|
||||||
|
import me.rhunk.snapenhance.Constants.VIEW_INJECTED_CODE
|
||||||
|
import me.rhunk.snapenhance.config.ConfigProperty
|
||||||
|
import me.rhunk.snapenhance.features.impl.downloader.MediaDownloader
|
||||||
|
import me.rhunk.snapenhance.features.impl.ui.menus.AbstractMenu
|
||||||
|
|
||||||
|
|
||||||
|
class ChatActionMenu : AbstractMenu() {
|
||||||
|
private fun wasInjectedView(view: View): Boolean {
|
||||||
|
if (view.getTag(VIEW_INJECTED_CODE) != null) return true
|
||||||
|
view.setTag(VIEW_INJECTED_CODE, true)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyButtonTheme(parent: View, button: Button) {
|
||||||
|
button.background.colorFilter = BlendModeColorFilter(Color.WHITE, BlendMode.SRC_ATOP)
|
||||||
|
button.setTextColor(Color.BLACK)
|
||||||
|
button.transformationMethod = null
|
||||||
|
val margin = TypedValue.applyDimension(
|
||||||
|
TypedValue.COMPLEX_UNIT_DIP,
|
||||||
|
20f,
|
||||||
|
Resources.getSystem().displayMetrics
|
||||||
|
).toInt()
|
||||||
|
val params = MarginLayoutParams(parent.layoutParams)
|
||||||
|
params.setMargins(margin, 5, margin, 5)
|
||||||
|
params.marginEnd = margin
|
||||||
|
params.marginStart = margin
|
||||||
|
button.layoutParams = params
|
||||||
|
button.height = TypedValue.applyDimension(
|
||||||
|
TypedValue.COMPLEX_UNIT_DIP,
|
||||||
|
50f,
|
||||||
|
Resources.getSystem().displayMetrics
|
||||||
|
).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("SetTextI18n")
|
||||||
|
fun inject(viewGroup: ViewGroup) {
|
||||||
|
val parent = viewGroup.parent.parent as ViewGroup
|
||||||
|
if (wasInjectedView(parent)) return
|
||||||
|
//close the action menu using a touch event
|
||||||
|
val closeActionMenu = {
|
||||||
|
viewGroup.dispatchTouchEvent(
|
||||||
|
MotionEvent.obtain(
|
||||||
|
SystemClock.uptimeMillis(),
|
||||||
|
SystemClock.uptimeMillis(),
|
||||||
|
MotionEvent.ACTION_DOWN,
|
||||||
|
0f,
|
||||||
|
0f,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (context.config.bool(ConfigProperty.DOWNLOAD_INCHAT_SNAPS)) {
|
||||||
|
val previewButton = Button(viewGroup.context)
|
||||||
|
applyButtonTheme(parent, previewButton)
|
||||||
|
previewButton.text = "Preview"
|
||||||
|
previewButton.setOnClickListener {
|
||||||
|
closeActionMenu()
|
||||||
|
context.executeAsync { context.feature(MediaDownloader::class).onMessageActionMenu(true) }
|
||||||
|
}
|
||||||
|
parent.addView(previewButton)
|
||||||
|
}
|
||||||
|
|
||||||
|
//download snap in chat
|
||||||
|
if (context.config.bool(ConfigProperty.DOWNLOAD_INCHAT_SNAPS)) {
|
||||||
|
val downloadButton = Button(viewGroup.context)
|
||||||
|
applyButtonTheme(parent, downloadButton)
|
||||||
|
downloadButton.text = "Download"
|
||||||
|
downloadButton.setOnClickListener {
|
||||||
|
closeActionMenu()
|
||||||
|
context.executeAsync { context.feature(MediaDownloader::class).onMessageActionMenu(false) }
|
||||||
|
}
|
||||||
|
parent.addView(downloadButton)
|
||||||
|
}
|
||||||
|
|
||||||
|
//TODO: delete logged message button
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,231 @@
|
|||||||
|
package me.rhunk.snapenhance.features.impl.ui.menus.impl
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.AlertDialog
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.DialogInterface
|
||||||
|
import android.content.res.Resources
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.drawable.BitmapDrawable
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.Button
|
||||||
|
import android.widget.CompoundButton
|
||||||
|
import android.widget.Switch
|
||||||
|
import android.widget.Toast
|
||||||
|
import de.robv.android.xposed.XposedBridge
|
||||||
|
import me.rhunk.snapenhance.Logger
|
||||||
|
import me.rhunk.snapenhance.config.ConfigProperty
|
||||||
|
import me.rhunk.snapenhance.data.ContentType
|
||||||
|
import me.rhunk.snapenhance.database.objects.ConversationMessage
|
||||||
|
import me.rhunk.snapenhance.database.objects.FriendInfo
|
||||||
|
import me.rhunk.snapenhance.database.objects.UserConversationLink
|
||||||
|
import me.rhunk.snapenhance.features.impl.Messaging
|
||||||
|
import me.rhunk.snapenhance.features.impl.spy.StealthMode
|
||||||
|
import me.rhunk.snapenhance.features.impl.ui.menus.AbstractMenu
|
||||||
|
import me.rhunk.snapenhance.features.impl.ui.menus.ViewAppearanceHelper.applyTheme
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.text.DateFormat
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
class FriendFeedInfoMenu : AbstractMenu() {
|
||||||
|
private fun getImageDrawable(url: String): Drawable {
|
||||||
|
val connection = URL(url).openConnection() as HttpURLConnection
|
||||||
|
connection.connect()
|
||||||
|
val input = connection.inputStream
|
||||||
|
return BitmapDrawable(Resources.getSystem(), BitmapFactory.decodeStream(input))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatDate(timestamp: Long): String? {
|
||||||
|
return SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH).format(Date(timestamp))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showProfileInfo(profile: FriendInfo) {
|
||||||
|
var icon: Drawable? = null
|
||||||
|
try {
|
||||||
|
if (profile.bitmojiSelfieId != null && profile.bitmojiAvatarId != null) {
|
||||||
|
icon = getImageDrawable(
|
||||||
|
"https://sdk.bitmoji.com/render/panel/" + profile.bitmojiSelfieId
|
||||||
|
.toString() + "-" + profile.bitmojiAvatarId
|
||||||
|
.toString() + "-v1.webp?transparent=1&scale=0"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.xposedLog(e)
|
||||||
|
}
|
||||||
|
val finalIcon = icon
|
||||||
|
context.runOnUiThread {
|
||||||
|
val addedTimestamp: Long = profile.addedTimestamp.coerceAtLeast(profile.reverseAddedTimestamp)
|
||||||
|
val builder = AlertDialog.Builder(context.mainActivity)
|
||||||
|
builder.setIcon(finalIcon)
|
||||||
|
builder.setTitle(profile.displayName)
|
||||||
|
val birthday = Calendar.getInstance()
|
||||||
|
birthday[Calendar.MONTH] = (profile.birthday shr 32).toInt() - 1
|
||||||
|
val message: String = """
|
||||||
|
${context.translation.get("info.username")}: ${profile.username}
|
||||||
|
${context.translation.get("info.display_name")}: ${profile.displayName}
|
||||||
|
${context.translation.get("info.added_date")}: ${formatDate(addedTimestamp)}
|
||||||
|
${birthday.getDisplayName(
|
||||||
|
Calendar.MONTH,
|
||||||
|
Calendar.LONG,
|
||||||
|
context.translation.getLocale()
|
||||||
|
)?.let {
|
||||||
|
context.translation.get("info.birthday")
|
||||||
|
.replace("{month}", it)
|
||||||
|
.replace("{day}", profile.birthday.toInt().toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
builder.setMessage(message)
|
||||||
|
builder.setPositiveButton(
|
||||||
|
"OK"
|
||||||
|
) { dialog: DialogInterface, _: Int -> dialog.dismiss() }
|
||||||
|
builder.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showPreview(userId: String?, conversationId: String, androidCtx: Context?) {
|
||||||
|
//query message
|
||||||
|
val messages: List<ConversationMessage>? = context.database.getMessagesFromConversationId(
|
||||||
|
conversationId,
|
||||||
|
context.config.int(ConfigProperty.MESSAGE_PREVIEW_LENGTH)
|
||||||
|
)?.reversed()
|
||||||
|
|
||||||
|
if (messages == null || messages.isEmpty()) {
|
||||||
|
Toast.makeText(androidCtx, "No messages found", Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val participants: Map<String, FriendInfo> = context.database.getConversationParticipants(conversationId)!!
|
||||||
|
.map { context.database.getFriendInfo(it)!! }
|
||||||
|
.associateBy { it.userId!! }
|
||||||
|
|
||||||
|
val messageBuilder = StringBuilder()
|
||||||
|
|
||||||
|
messages.forEach{ message: ConversationMessage ->
|
||||||
|
val sender: FriendInfo? = participants[message.sender_id]
|
||||||
|
|
||||||
|
var messageString: String = message.getMessageAsString() ?: ContentType.fromId(message.content_type).name
|
||||||
|
|
||||||
|
if (message.content_type == ContentType.SNAP.id) {
|
||||||
|
val readTimeStamp: Long = message.read_timestamp
|
||||||
|
messageString = "\uD83D\uDFE5" //red square
|
||||||
|
if (readTimeStamp > 0) {
|
||||||
|
messageString += " \uD83D\uDC40 " //eyes
|
||||||
|
messageString += DateFormat.getDateTimeInstance(
|
||||||
|
DateFormat.SHORT,
|
||||||
|
DateFormat.SHORT
|
||||||
|
).format(Date(readTimeStamp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayUsername = sender?.displayName ?: "Unknown user"
|
||||||
|
|
||||||
|
if (displayUsername.length > 12) {
|
||||||
|
displayUsername = displayUsername.substring(0, 13) + "... "
|
||||||
|
}
|
||||||
|
|
||||||
|
messageBuilder.append(displayUsername).append(": ").append(messageString).append("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
val targetPerson: FriendInfo? =
|
||||||
|
if (userId == null) null else participants[userId]
|
||||||
|
|
||||||
|
targetPerson?.let {
|
||||||
|
val timeSecondDiff = ((it.streakExpirationTimestamp - System.currentTimeMillis()) / 1000 / 60).toInt()
|
||||||
|
messageBuilder.append("\n\n")
|
||||||
|
.append("\uD83D\uDD25 ") //fire emoji
|
||||||
|
.append(context.translation.get("streak_expiration").format(
|
||||||
|
timeSecondDiff / 60 / 24,
|
||||||
|
timeSecondDiff / 60 % 24,
|
||||||
|
timeSecondDiff % 60
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
//alert dialog
|
||||||
|
val builder = AlertDialog.Builder(context.mainActivity)
|
||||||
|
builder.setTitle(context.translation.get("preview"))
|
||||||
|
builder.setMessage(messageBuilder.toString())
|
||||||
|
builder.setPositiveButton(
|
||||||
|
"OK"
|
||||||
|
) { dialog: DialogInterface, _: Int -> dialog.dismiss() }
|
||||||
|
targetPerson?.let {
|
||||||
|
builder.setNegativeButton(context.translation.get("profile_info")) {_, _ ->
|
||||||
|
context.executeAsync {
|
||||||
|
showProfileInfo(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
builder.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("SetTextI18n", "UseSwitchCompatOrMaterialCode", "DefaultLocale")
|
||||||
|
fun inject(viewModel: View, viewConsumer: ((View) -> Unit)) {
|
||||||
|
val messaging = context.feature(Messaging::class)
|
||||||
|
var focusedConversationTargetUser: String? = null
|
||||||
|
val conversationId: String
|
||||||
|
if (messaging.lastFetchConversationUserUUID != null) {
|
||||||
|
focusedConversationTargetUser = messaging.lastFetchConversationUserUUID.toString()
|
||||||
|
val conversation: UserConversationLink = context.database.getDMConversationIdFromUserId(focusedConversationTargetUser) ?: return
|
||||||
|
conversationId = conversation.client_conversation_id!!.trim().lowercase()
|
||||||
|
} else {
|
||||||
|
conversationId = messaging.lastFetchConversationUUID.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
//preview button
|
||||||
|
val previewButton = Button(viewModel.context)
|
||||||
|
previewButton.text = context.translation.get("preview")
|
||||||
|
applyTheme(viewModel, previewButton)
|
||||||
|
val finalFocusedConversationTargetUser = focusedConversationTargetUser
|
||||||
|
previewButton.setOnClickListener { v: View? ->
|
||||||
|
showPreview(
|
||||||
|
finalFocusedConversationTargetUser,
|
||||||
|
conversationId,
|
||||||
|
previewButton.context
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
//export conversation
|
||||||
|
/*val exportButton = Button(viewModel.context)
|
||||||
|
exportButton.setText(context.translation.get("conversation_export"))
|
||||||
|
applyTheme(viewModel, exportButton)
|
||||||
|
exportButton.setOnClickListener { event: View? ->
|
||||||
|
conversationExport.exportConversation(
|
||||||
|
SnapUUID(conversationId)
|
||||||
|
)
|
||||||
|
}*/
|
||||||
|
|
||||||
|
//stealth switch
|
||||||
|
val stealthSwitch = Switch(viewModel.context)
|
||||||
|
stealthSwitch.text = context.translation.get("stealth_mode")
|
||||||
|
stealthSwitch.isChecked = context.feature(StealthMode::class).isStealth(conversationId)
|
||||||
|
applyTheme(viewModel, stealthSwitch)
|
||||||
|
stealthSwitch.setOnCheckedChangeListener { _: CompoundButton?, isChecked: Boolean ->
|
||||||
|
context.feature(StealthMode::class).setStealth(
|
||||||
|
conversationId,
|
||||||
|
isChecked
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*//click to delete switch
|
||||||
|
val clickToDeleteSwitch = Switch(viewModel.context)
|
||||||
|
clickToDeleteSwitch.setText(context.translation.get("click_to_delete"))
|
||||||
|
clickToDeleteSwitch.isChecked = clickToDelete.isClickToDelete(conversationId)
|
||||||
|
applyTheme(viewModel, clickToDeleteSwitch)
|
||||||
|
clickToDeleteSwitch.setOnCheckedChangeListener { buttonView: CompoundButton?, isChecked: Boolean ->
|
||||||
|
clickToDelete.setClickToDelete(
|
||||||
|
conversationId,
|
||||||
|
isChecked
|
||||||
|
)
|
||||||
|
}*/
|
||||||
|
/* if (configManager.getBoolean(ConfigCategory.EXTRAS, "conversation_export")
|
||||||
|
.isState()
|
||||||
|
) viewConsumer.accept(exportButton)
|
||||||
|
if (configManager.getBoolean(ConfigCategory.PRIVACY, "click_to_delete")
|
||||||
|
.isState()
|
||||||
|
) viewConsumer.accept(clickToDeleteSwitch)*/
|
||||||
|
viewConsumer(stealthSwitch)
|
||||||
|
viewConsumer(previewButton)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,82 @@
|
|||||||
|
package me.rhunk.snapenhance.features.impl.ui.menus.impl
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.Button
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.ScrollView
|
||||||
|
import de.robv.android.xposed.XposedBridge
|
||||||
|
import me.rhunk.snapenhance.Constants
|
||||||
|
import me.rhunk.snapenhance.Logger
|
||||||
|
import me.rhunk.snapenhance.features.impl.downloader.MediaDownloader
|
||||||
|
import me.rhunk.snapenhance.features.impl.ui.menus.AbstractMenu
|
||||||
|
import me.rhunk.snapenhance.features.impl.ui.menus.ViewAppearanceHelper.applyTheme
|
||||||
|
|
||||||
|
class OperaContextActionMenu : AbstractMenu() {
|
||||||
|
private val contextCardsScrollView by lazy {
|
||||||
|
context.resources.getIdentifier("context_cards_scroll_view", "id", Constants.SNAPCHAT_PACKAGE_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
LinearLayout :
|
||||||
|
- LinearLayout:
|
||||||
|
- SnapFontTextView
|
||||||
|
- ImageView
|
||||||
|
- LinearLayout:
|
||||||
|
- SnapFontTextView
|
||||||
|
- ImageView
|
||||||
|
- LinearLayout:
|
||||||
|
- SnapFontTextView
|
||||||
|
- ImageView
|
||||||
|
*/
|
||||||
|
private fun isViewGroupButtonMenuContainer(viewGroup: ViewGroup): Boolean {
|
||||||
|
if (viewGroup !is LinearLayout) return false
|
||||||
|
val children = ArrayList<View>()
|
||||||
|
for (i in 0 until viewGroup.getChildCount())
|
||||||
|
children.add(viewGroup.getChildAt(i))
|
||||||
|
return if (children.any { view: View? -> view !is LinearLayout })
|
||||||
|
false
|
||||||
|
else children.map { view: View -> view as LinearLayout }
|
||||||
|
.any { linearLayout: LinearLayout ->
|
||||||
|
val viewChildren = ArrayList<View>()
|
||||||
|
for (i in 0 until linearLayout.childCount) viewChildren.add(
|
||||||
|
linearLayout.getChildAt(
|
||||||
|
i
|
||||||
|
)
|
||||||
|
)
|
||||||
|
viewChildren.any { viewChild: View ->
|
||||||
|
viewChild.javaClass.name.endsWith("SnapFontTextView")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("SetTextI18n")
|
||||||
|
fun inject(viewGroup: ViewGroup, childView: View) {
|
||||||
|
try {
|
||||||
|
if (viewGroup.parent !is ScrollView) return
|
||||||
|
val parent = viewGroup.parent as ScrollView
|
||||||
|
if (parent.id != contextCardsScrollView) return
|
||||||
|
if (childView !is LinearLayout) return
|
||||||
|
if (!isViewGroupButtonMenuContainer(childView as ViewGroup)) return
|
||||||
|
|
||||||
|
val linearLayout = LinearLayout(childView.getContext())
|
||||||
|
linearLayout.orientation = LinearLayout.VERTICAL
|
||||||
|
linearLayout.gravity = Gravity.CENTER
|
||||||
|
linearLayout.layoutParams =
|
||||||
|
LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
)
|
||||||
|
val button = Button(childView.getContext())
|
||||||
|
button.text = context.translation.get("download_opera")
|
||||||
|
button.setOnClickListener { context.feature(MediaDownloader::class).downloadLastOperaMediaAsync() }
|
||||||
|
applyTheme(linearLayout, button)
|
||||||
|
linearLayout.addView(button)
|
||||||
|
(childView as ViewGroup).addView(linearLayout, 0)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.xposedLog(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,133 @@
|
|||||||
|
package me.rhunk.snapenhance.features.impl.ui.menus.impl
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.AlertDialog
|
||||||
|
import android.text.InputType
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.Button
|
||||||
|
import android.widget.EditText
|
||||||
|
import android.widget.Switch
|
||||||
|
import android.widget.TextView
|
||||||
|
import me.rhunk.snapenhance.BuildConfig
|
||||||
|
import me.rhunk.snapenhance.Constants
|
||||||
|
import me.rhunk.snapenhance.config.ConfigProperty
|
||||||
|
import me.rhunk.snapenhance.features.impl.ui.menus.AbstractMenu
|
||||||
|
import me.rhunk.snapenhance.features.impl.ui.menus.ViewAppearanceHelper
|
||||||
|
|
||||||
|
class SettingsMenu : AbstractMenu() {
|
||||||
|
private fun createCategoryTitle(viewModel: View, key: String): TextView {
|
||||||
|
val categoryText = TextView(viewModel.context)
|
||||||
|
categoryText.text = context.translation.get(key)
|
||||||
|
ViewAppearanceHelper.applyTheme(viewModel, categoryText)
|
||||||
|
categoryText.textSize = 18f
|
||||||
|
return categoryText
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("SetTextI18n")
|
||||||
|
private fun createPropertyView(viewModel: View, property: ConfigProperty): View {
|
||||||
|
val updateButtonText: (TextView, String) -> Unit = { textView, text ->
|
||||||
|
textView.text = "${context.translation.get(property.nameKey)} $text"
|
||||||
|
}
|
||||||
|
|
||||||
|
val textEditor: ((String) -> Unit) -> Unit = { updateValue ->
|
||||||
|
val builder = AlertDialog.Builder(viewModel.context)
|
||||||
|
builder.setTitle(context.translation.get(property.nameKey))
|
||||||
|
|
||||||
|
val input = EditText(viewModel.context)
|
||||||
|
input.inputType = InputType.TYPE_CLASS_TEXT
|
||||||
|
input.setText(context.config.string(property))
|
||||||
|
|
||||||
|
builder.setView(input)
|
||||||
|
builder.setPositiveButton("OK") { _, _ ->
|
||||||
|
updateValue(input.text.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.setNegativeButton("Cancel") { dialog, _ -> dialog.cancel() }
|
||||||
|
builder.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
val resultView: View = when (property.defaultValue) {
|
||||||
|
is String -> {
|
||||||
|
val textView = TextView(viewModel.context)
|
||||||
|
updateButtonText(textView, context.config.string(property))
|
||||||
|
ViewAppearanceHelper.applyTheme(viewModel, textView)
|
||||||
|
textView.setOnClickListener {
|
||||||
|
textEditor { value ->
|
||||||
|
context.config.set(property, value)
|
||||||
|
updateButtonText(textView, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
textView
|
||||||
|
}
|
||||||
|
is Number -> {
|
||||||
|
val button = Button(viewModel.context)
|
||||||
|
updateButtonText(button, context.config.get(property).toString())
|
||||||
|
button.setOnClickListener {
|
||||||
|
textEditor { value ->
|
||||||
|
runCatching {
|
||||||
|
context.config.set(property, when (property.defaultValue) {
|
||||||
|
is Int -> value.toInt()
|
||||||
|
is Double -> value.toDouble()
|
||||||
|
is Float -> value.toFloat()
|
||||||
|
is Long -> value.toLong()
|
||||||
|
is Short -> value.toShort()
|
||||||
|
is Byte -> value.toByte()
|
||||||
|
else -> throw IllegalArgumentException()
|
||||||
|
})
|
||||||
|
updateButtonText(button, value)
|
||||||
|
}.onFailure {
|
||||||
|
context.shortToast("Invalid value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ViewAppearanceHelper.applyTheme(viewModel, button)
|
||||||
|
button
|
||||||
|
}
|
||||||
|
is Boolean -> {
|
||||||
|
val switch = Switch(viewModel.context)
|
||||||
|
switch.text = context.translation.get(property.nameKey)
|
||||||
|
switch.isChecked = context.config.bool(property)
|
||||||
|
switch.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
context.config.set(property, isChecked)
|
||||||
|
}
|
||||||
|
ViewAppearanceHelper.applyTheme(viewModel, switch)
|
||||||
|
switch
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
TextView(viewModel.context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resultView
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("SetTextI18n")
|
||||||
|
fun inject(viewModel: View, addView: (View) -> Unit) {
|
||||||
|
val packageInfo = viewModel.context.packageManager.getPackageInfo(
|
||||||
|
Constants.SNAPCHAT_PACKAGE_NAME,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
val versionTextBuilder = StringBuilder()
|
||||||
|
versionTextBuilder.append("SnapEnhance ").append(BuildConfig.VERSION_NAME)
|
||||||
|
.append(" by rhunk")
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
versionTextBuilder.append("\n").append("Snapchat ").append(packageInfo.versionName)
|
||||||
|
.append(" (").append(packageInfo.longVersionCode).append(")")
|
||||||
|
}
|
||||||
|
val titleText = TextView(viewModel.context)
|
||||||
|
titleText.text = versionTextBuilder.toString()
|
||||||
|
ViewAppearanceHelper.applyTheme(viewModel, titleText)
|
||||||
|
titleText.textSize = 18f
|
||||||
|
titleText.minHeight = 80 * versionTextBuilder.chars().filter { ch: Int -> ch == '\n'.code }
|
||||||
|
.count().coerceAtLeast(2).toInt()
|
||||||
|
addView(titleText)
|
||||||
|
|
||||||
|
context.config.entries().groupBy {
|
||||||
|
it.key.category
|
||||||
|
}.forEach { (category, value) ->
|
||||||
|
addView(createCategoryTitle(viewModel, category.key))
|
||||||
|
value.forEach {
|
||||||
|
addView(createPropertyView(viewModel, it.key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
72
app/src/main/kotlin/me/rhunk/snapenhance/hook/HookAdapter.kt
Normal file
72
app/src/main/kotlin/me/rhunk/snapenhance/hook/HookAdapter.kt
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
package me.rhunk.snapenhance.hook
|
||||||
|
|
||||||
|
import de.robv.android.xposed.XC_MethodHook
|
||||||
|
import de.robv.android.xposed.XposedBridge
|
||||||
|
import java.lang.reflect.Member
|
||||||
|
import java.util.function.Consumer
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
class HookAdapter(
|
||||||
|
private val methodHookParam: XC_MethodHook.MethodHookParam<*>
|
||||||
|
) {
|
||||||
|
fun <T : Any> thisObject(): T {
|
||||||
|
return methodHookParam.thisObject as T
|
||||||
|
}
|
||||||
|
|
||||||
|
fun method(): Member {
|
||||||
|
return methodHookParam.method
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T : Any> arg(index: Int): T {
|
||||||
|
return methodHookParam.args[index] as T
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T : Any> argNullable(index: Int): T? {
|
||||||
|
return methodHookParam.args[index] as T?
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setArg(index: Int, value: Any?) {
|
||||||
|
if (index < 0 || index >= methodHookParam.args.size) return
|
||||||
|
methodHookParam.args[index] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun args(): Array<Any> {
|
||||||
|
return methodHookParam.args
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getResult(): Any? {
|
||||||
|
return methodHookParam.result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setResult(result: Any?) {
|
||||||
|
methodHookParam.result = result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setThrowable(throwable: Throwable) {
|
||||||
|
methodHookParam.throwable = throwable
|
||||||
|
}
|
||||||
|
|
||||||
|
fun throwable(): Throwable? {
|
||||||
|
return methodHookParam.throwable
|
||||||
|
}
|
||||||
|
|
||||||
|
fun invokeOriginal(): Any? {
|
||||||
|
return XposedBridge.invokeOriginalMethod(method(), thisObject(), args())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun invokeOriginal(args: Array<Any>): Any? {
|
||||||
|
return XposedBridge.invokeOriginalMethod(method(), thisObject(), args)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun invokeOriginalSafe(errorCallback: Consumer<Throwable>) {
|
||||||
|
invokeOriginalSafe(args(), errorCallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun invokeOriginalSafe(args: Array<Any>, errorCallback: Consumer<Throwable>) {
|
||||||
|
runCatching {
|
||||||
|
setResult(XposedBridge.invokeOriginalMethod(method(), thisObject(), args))
|
||||||
|
}.onFailure {
|
||||||
|
errorCallback.accept(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
package me.rhunk.snapenhance.hook
|
||||||
|
|
||||||
|
enum class HookStage {
|
||||||
|
BEFORE,
|
||||||
|
AFTER
|
||||||
|
}
|
94
app/src/main/kotlin/me/rhunk/snapenhance/hook/Hooker.kt
Normal file
94
app/src/main/kotlin/me/rhunk/snapenhance/hook/Hooker.kt
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
package me.rhunk.snapenhance.hook
|
||||||
|
|
||||||
|
import de.robv.android.xposed.XC_MethodHook
|
||||||
|
import de.robv.android.xposed.XposedBridge
|
||||||
|
import java.lang.reflect.Member
|
||||||
|
|
||||||
|
object Hooker {
|
||||||
|
private fun newMethodHook(
|
||||||
|
stage: HookStage,
|
||||||
|
consumer: (HookAdapter) -> Unit,
|
||||||
|
filter: ((HookAdapter) -> Boolean) = { true }
|
||||||
|
) = object : XC_MethodHook() {
|
||||||
|
override fun beforeHookedMethod(param: MethodHookParam<*>) {
|
||||||
|
if (stage != HookStage.BEFORE) return
|
||||||
|
with(HookAdapter(param)) {
|
||||||
|
if (!filter(this)) return
|
||||||
|
consumer(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun afterHookedMethod(param: MethodHookParam<*>) {
|
||||||
|
if (stage != HookStage.AFTER) return
|
||||||
|
with(HookAdapter(param)) {
|
||||||
|
if (!filter(this)) return
|
||||||
|
consumer(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hook(
|
||||||
|
clazz: Class<*>,
|
||||||
|
methodName: String,
|
||||||
|
stage: HookStage,
|
||||||
|
consumer: (HookAdapter) -> Unit
|
||||||
|
): Set<XC_MethodHook.Unhook> = XposedBridge.hookAllMethods(clazz, methodName, newMethodHook(stage, consumer))
|
||||||
|
|
||||||
|
fun hook(
|
||||||
|
clazz: Class<*>,
|
||||||
|
methodName: String,
|
||||||
|
stage: HookStage,
|
||||||
|
filter: (HookAdapter) -> Boolean,
|
||||||
|
consumer: (HookAdapter) -> Unit
|
||||||
|
): Set<XC_MethodHook.Unhook> = XposedBridge.hookAllMethods(clazz, methodName, newMethodHook(stage, consumer, filter))
|
||||||
|
|
||||||
|
fun hook(
|
||||||
|
member: Member,
|
||||||
|
stage: HookStage,
|
||||||
|
consumer: (HookAdapter) -> Unit
|
||||||
|
): XC_MethodHook.Unhook {
|
||||||
|
return XposedBridge.hookMethod(member, newMethodHook(stage, consumer))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hook(
|
||||||
|
member: Member,
|
||||||
|
stage: HookStage,
|
||||||
|
filter: ((HookAdapter) -> Boolean),
|
||||||
|
consumer: (HookAdapter) -> Unit
|
||||||
|
): XC_MethodHook.Unhook {
|
||||||
|
return XposedBridge.hookMethod(member, newMethodHook(stage, consumer, filter))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun hookConstructor(
|
||||||
|
clazz: Class<*>,
|
||||||
|
stage: HookStage,
|
||||||
|
consumer: (HookAdapter) -> Unit
|
||||||
|
) {
|
||||||
|
XposedBridge.hookAllConstructors(clazz, newMethodHook(stage, consumer))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hookConstructor(
|
||||||
|
clazz: Class<*>,
|
||||||
|
stage: HookStage,
|
||||||
|
filter: ((HookAdapter) -> Boolean),
|
||||||
|
consumer: (HookAdapter) -> Unit
|
||||||
|
) {
|
||||||
|
XposedBridge.hookAllConstructors(clazz, newMethodHook(stage, consumer, filter))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ephemeralHookObjectMethod(
|
||||||
|
clazz: Class<*>,
|
||||||
|
instance: Any,
|
||||||
|
methodName: String,
|
||||||
|
stage: HookStage,
|
||||||
|
hookConsumer: (HookAdapter) -> Unit
|
||||||
|
) {
|
||||||
|
val unhooks: MutableSet<XC_MethodHook.Unhook> = HashSet()
|
||||||
|
hook(clazz, methodName, stage) { param->
|
||||||
|
if (param.thisObject<Any>() != instance) return@hook
|
||||||
|
hookConsumer(param)
|
||||||
|
unhooks.forEach{ it.unhook() }
|
||||||
|
}.also { unhooks.addAll(it) }
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
package me.rhunk.snapenhance.manager
|
||||||
|
|
||||||
|
interface Manager {
|
||||||
|
fun init() {}
|
||||||
|
fun onActivityCreate() {}
|
||||||
|
}
|
@ -0,0 +1,63 @@
|
|||||||
|
package me.rhunk.snapenhance.manager.impl
|
||||||
|
|
||||||
|
import com.google.gson.JsonObject
|
||||||
|
import me.rhunk.snapenhance.Logger
|
||||||
|
import me.rhunk.snapenhance.ModContext
|
||||||
|
import me.rhunk.snapenhance.bridge.common.impl.FileAccessRequest
|
||||||
|
import me.rhunk.snapenhance.config.ConfigAccessor
|
||||||
|
import me.rhunk.snapenhance.config.ConfigProperty
|
||||||
|
import me.rhunk.snapenhance.manager.Manager
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
|
||||||
|
class ConfigManager(
|
||||||
|
private val context: ModContext,
|
||||||
|
config: MutableMap<ConfigProperty, Any?> = mutableMapOf()
|
||||||
|
) : ConfigAccessor(config), Manager {
|
||||||
|
|
||||||
|
private val propertyList = ConfigProperty.sortedByCategory()
|
||||||
|
|
||||||
|
override fun init() {
|
||||||
|
//generate default config
|
||||||
|
propertyList.forEach { key ->
|
||||||
|
set(key, key.defaultValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.bridgeClient.isFileExists(FileAccessRequest.FileType.CONFIG)) {
|
||||||
|
writeConfig()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
loadConfig()
|
||||||
|
}.onFailure {
|
||||||
|
Logger.xposedLog("Failed to load config", it)
|
||||||
|
writeConfig()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadConfig() {
|
||||||
|
val configContent = context.bridgeClient.createAndReadFile(
|
||||||
|
FileAccessRequest.FileType.CONFIG,
|
||||||
|
"{}".toByteArray(Charsets.UTF_8)
|
||||||
|
)
|
||||||
|
val configObject: JsonObject = context.gson.fromJson(
|
||||||
|
String(configContent, StandardCharsets.UTF_8),
|
||||||
|
JsonObject::class.java
|
||||||
|
)
|
||||||
|
propertyList.forEach { key ->
|
||||||
|
val value = context.gson.fromJson(configObject.get(key.name), key.defaultValue.javaClass) ?: key.defaultValue
|
||||||
|
set(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeConfig() {
|
||||||
|
val configObject = JsonObject()
|
||||||
|
propertyList.forEach { key ->
|
||||||
|
configObject.add(key.name, context.gson.toJsonTree(get(key)))
|
||||||
|
}
|
||||||
|
context.bridgeClient.writeFile(
|
||||||
|
FileAccessRequest.FileType.CONFIG,
|
||||||
|
context.gson.toJson(configObject).toByteArray(Charsets.UTF_8)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,93 @@
|
|||||||
|
package me.rhunk.snapenhance.manager.impl
|
||||||
|
|
||||||
|
import me.rhunk.snapenhance.Logger
|
||||||
|
import me.rhunk.snapenhance.ModContext
|
||||||
|
import me.rhunk.snapenhance.features.Feature
|
||||||
|
import me.rhunk.snapenhance.features.FeatureLoadParams
|
||||||
|
import me.rhunk.snapenhance.features.impl.ConfigEnumKeys
|
||||||
|
import me.rhunk.snapenhance.features.impl.Messaging
|
||||||
|
import me.rhunk.snapenhance.features.impl.downloader.MediaDownloader
|
||||||
|
import me.rhunk.snapenhance.features.impl.extras.AutoSave
|
||||||
|
import me.rhunk.snapenhance.features.impl.extras.Notifications
|
||||||
|
import me.rhunk.snapenhance.features.impl.extras.SnapchatPlus
|
||||||
|
import me.rhunk.snapenhance.features.impl.privacy.DisableMetrics
|
||||||
|
import me.rhunk.snapenhance.features.impl.privacy.PreventScreenshotDetections
|
||||||
|
import me.rhunk.snapenhance.features.impl.spy.*
|
||||||
|
import me.rhunk.snapenhance.features.impl.ui.UITweaks
|
||||||
|
import me.rhunk.snapenhance.features.impl.ui.menus.MenuViewInjector
|
||||||
|
import me.rhunk.snapenhance.manager.Manager
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
|
class FeatureManager(private val context: ModContext) : Manager {
|
||||||
|
private val asyncLoadExecutorService = Executors.newCachedThreadPool()
|
||||||
|
private val features = mutableListOf<Feature>()
|
||||||
|
|
||||||
|
private fun register(featureClass: KClass<out Feature>) {
|
||||||
|
runCatching {
|
||||||
|
with(featureClass.java.newInstance()) {
|
||||||
|
if (loadParams and FeatureLoadParams.NO_INIT != 0) return@with
|
||||||
|
context = this@FeatureManager.context
|
||||||
|
features.add(this)
|
||||||
|
}
|
||||||
|
}.onFailure {
|
||||||
|
Logger.xposedLog("Failed to register feature ${featureClass.simpleName}", it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
fun <T : Feature> get(featureClass: KClass<T>): T? {
|
||||||
|
return features.find { it::class == featureClass } as? T
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun init() {
|
||||||
|
register(Messaging::class)
|
||||||
|
register(MediaDownloader::class)
|
||||||
|
register(StealthMode::class)
|
||||||
|
register(MenuViewInjector::class)
|
||||||
|
register(PreventReadReceipts::class)
|
||||||
|
register(AnonymousStoryViewing::class)
|
||||||
|
register(MessageLogger::class)
|
||||||
|
register(SnapchatPlus::class)
|
||||||
|
register(DisableMetrics::class)
|
||||||
|
register(PreventScreenshotDetections::class)
|
||||||
|
register(PreventStatusNotifications::class)
|
||||||
|
register(Notifications::class)
|
||||||
|
register(AutoSave::class)
|
||||||
|
register(UITweaks::class)
|
||||||
|
register(ConfigEnumKeys::class)
|
||||||
|
|
||||||
|
initializeFeatures()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun featureInitializer(isAsync: Boolean, param: Int, action: (Feature) -> Unit) {
|
||||||
|
features.forEach { feature ->
|
||||||
|
if (feature.loadParams and param == 0) return@forEach
|
||||||
|
val callback = {
|
||||||
|
runCatching {
|
||||||
|
action(feature)
|
||||||
|
}.onFailure {
|
||||||
|
Logger.xposedLog("Failed to init feature ${feature.nameKey}", it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isAsync) {
|
||||||
|
callback()
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
asyncLoadExecutorService.submit {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initializeFeatures() {
|
||||||
|
//TODO: async called when all features are initiated ?
|
||||||
|
featureInitializer(false, FeatureLoadParams.INIT_SYNC) { it.init() }
|
||||||
|
featureInitializer(true, FeatureLoadParams.INIT_ASYNC) { it.asyncInit() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityCreate() {
|
||||||
|
featureInitializer(false, FeatureLoadParams.ACTIVITY_CREATE_SYNC) { it.onActivityCreate() }
|
||||||
|
featureInitializer(true, FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { it.asyncOnActivityCreate() }
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,178 @@
|
|||||||
|
package me.rhunk.snapenhance.manager.impl
|
||||||
|
|
||||||
|
import com.google.gson.JsonElement
|
||||||
|
import com.google.gson.JsonObject
|
||||||
|
import com.google.gson.JsonParser
|
||||||
|
import dalvik.system.DexFile
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import me.rhunk.snapenhance.Constants
|
||||||
|
import me.rhunk.snapenhance.Logger
|
||||||
|
import me.rhunk.snapenhance.ModContext
|
||||||
|
import me.rhunk.snapenhance.bridge.common.impl.FileAccessRequest
|
||||||
|
import me.rhunk.snapenhance.manager.Manager
|
||||||
|
import me.rhunk.snapenhance.mapping.Mapper
|
||||||
|
import me.rhunk.snapenhance.mapping.impl.CallbackMapper
|
||||||
|
import me.rhunk.snapenhance.mapping.impl.EnumMapper
|
||||||
|
import me.rhunk.snapenhance.mapping.impl.OperaPageViewControllerMapper
|
||||||
|
import me.rhunk.snapenhance.mapping.impl.PlusSubscriptionMapper
|
||||||
|
import me.rhunk.snapenhance.util.getObjectField
|
||||||
|
import java.io.FileNotFoundException
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
class MappingManager(private val context: ModContext) : Manager {
|
||||||
|
private val mappers = mutableListOf<Mapper>().apply {
|
||||||
|
add(CallbackMapper())
|
||||||
|
add(EnumMapper())
|
||||||
|
add(OperaPageViewControllerMapper())
|
||||||
|
add(PlusSubscriptionMapper())
|
||||||
|
}
|
||||||
|
|
||||||
|
private val mappings = ConcurrentHashMap<String, Any>()
|
||||||
|
private var snapBuildNumber = 0
|
||||||
|
|
||||||
|
override fun init() {
|
||||||
|
val currentBuildNumber = context.androidContext.packageManager.getPackageInfo(
|
||||||
|
Constants.SNAPCHAT_PACKAGE_NAME,
|
||||||
|
0
|
||||||
|
).longVersionCode.toInt()
|
||||||
|
snapBuildNumber = currentBuildNumber
|
||||||
|
|
||||||
|
if (context.bridgeClient.isFileExists(FileAccessRequest.FileType.MAPPINGS)) {
|
||||||
|
runCatching {
|
||||||
|
loadCached()
|
||||||
|
}.onFailure {
|
||||||
|
if (it is FileNotFoundException) {
|
||||||
|
Logger.xposedLog(it)
|
||||||
|
context.forceCloseApp()
|
||||||
|
}
|
||||||
|
Logger.error("Failed to load cached mappings", it)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadCached() {
|
||||||
|
if (!context.bridgeClient.isFileExists(FileAccessRequest.FileType.MAPPINGS)) {
|
||||||
|
Logger.xposedLog("Mappings file does not exist")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val mappingsObject = JsonParser.parseString(
|
||||||
|
String(
|
||||||
|
context.bridgeClient.readFile(FileAccessRequest.FileType.MAPPINGS),
|
||||||
|
StandardCharsets.UTF_8
|
||||||
|
)
|
||||||
|
).asJsonObject.also {
|
||||||
|
snapBuildNumber = it["snap_build_number"].asInt
|
||||||
|
}
|
||||||
|
|
||||||
|
mappingsObject.entrySet().forEach { (key, value): Map.Entry<String, JsonElement> ->
|
||||||
|
if (value.isJsonArray) {
|
||||||
|
mappings[key] = context.gson.fromJson(value, ArrayList::class.java)
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
if (value.isJsonObject) {
|
||||||
|
mappings[key] = context.gson.fromJson(value, ConcurrentHashMap::class.java)
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
mappings[key] = value.asString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun executeMappers(classes: List<Class<*>>) = runBlocking {
|
||||||
|
val jobs = mutableListOf<Job>()
|
||||||
|
mappers.forEach { mapper ->
|
||||||
|
mapper.context = context
|
||||||
|
launch {
|
||||||
|
runCatching {
|
||||||
|
mapper.useClasses(context.androidContext.classLoader, classes, mappings)
|
||||||
|
}.onFailure {
|
||||||
|
Logger.error("Failed to execute mapper ${mapper.javaClass.simpleName}", it)
|
||||||
|
}
|
||||||
|
}.also { jobs.add(it) }
|
||||||
|
}
|
||||||
|
jobs.forEach { it.join() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST", "DEPRECATION")
|
||||||
|
private fun refresh() {
|
||||||
|
context.shortToast("Loading mappings (this may take a while)")
|
||||||
|
val classes: MutableList<Class<*>> = ArrayList()
|
||||||
|
|
||||||
|
val classLoader = context.androidContext.classLoader
|
||||||
|
val dexPathList = classLoader.getObjectField("pathList")
|
||||||
|
val dexElements = dexPathList.getObjectField("dexElements") as Array<Any>
|
||||||
|
|
||||||
|
dexElements.forEach { dexElement: Any ->
|
||||||
|
val dexFile = dexElement.getObjectField("dexFile") as DexFile
|
||||||
|
dexFile.entries().toList().forEach fileList@{ className ->
|
||||||
|
//ignore classes without a dot in them
|
||||||
|
if (className.contains(".") && !className.startsWith("com.snap")) return@fileList
|
||||||
|
runCatching {
|
||||||
|
classLoader.loadClass(className)?.let { classes.add(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
executeMappers(classes)
|
||||||
|
write()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun write() {
|
||||||
|
val mappingsObject = JsonObject()
|
||||||
|
mappingsObject.addProperty("snap_build_number", snapBuildNumber)
|
||||||
|
mappings.forEach { (key, value) ->
|
||||||
|
if (value is List<*>) {
|
||||||
|
mappingsObject.add(key, context.gson.toJsonTree(value))
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
if (value is Map<*, *>) {
|
||||||
|
mappingsObject.add(key, context.gson.toJsonTree(value))
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
mappingsObject.addProperty(key, value.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
context.bridgeClient.writeFile(
|
||||||
|
FileAccessRequest.FileType.MAPPINGS,
|
||||||
|
mappingsObject.toString().toByteArray()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMappedObject(key: String): Any {
|
||||||
|
if (mappings.containsKey(key)) {
|
||||||
|
return mappings[key]!!
|
||||||
|
}
|
||||||
|
Logger.xposedLog("Mapping not found deleting cache")
|
||||||
|
context.bridgeClient.deleteFile(FileAccessRequest.FileType.MAPPINGS)
|
||||||
|
throw Exception("No mapping found for $key")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMappedClass(className: String): Class<*> {
|
||||||
|
return context.androidContext.classLoader.loadClass(getMappedObject(className) as String)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMappedClass(key: String, subKey: String): Class<*> {
|
||||||
|
return context.androidContext.classLoader.loadClass(getMappedValue(key, subKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMappedValue(key: String): String {
|
||||||
|
return getMappedObject(key) as String
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T : Any> getMappedList(key: String): List<T> {
|
||||||
|
return listOf(getMappedObject(key) as List<T>).flatten()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMappedValue(key: String, subKey: String): String {
|
||||||
|
return getMappedMap(key)[subKey] as String
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMappedMap(key: String): Map<String, *> {
|
||||||
|
return getMappedObject(key) as Map<String, *>
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,19 @@
|
|||||||
|
package me.rhunk.snapenhance.manager.impl
|
||||||
|
|
||||||
|
import me.rhunk.snapenhance.ModContext
|
||||||
|
import me.rhunk.snapenhance.manager.Manager
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
class TranslationManager(
|
||||||
|
private val context: ModContext
|
||||||
|
) : Manager {
|
||||||
|
override fun init() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getLocale(): Locale = Locale.getDefault()
|
||||||
|
|
||||||
|
fun get(key: String): String {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
}
|
13
app/src/main/kotlin/me/rhunk/snapenhance/mapping/Mapper.kt
Normal file
13
app/src/main/kotlin/me/rhunk/snapenhance/mapping/Mapper.kt
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
package me.rhunk.snapenhance.mapping
|
||||||
|
|
||||||
|
import me.rhunk.snapenhance.ModContext
|
||||||
|
|
||||||
|
abstract class Mapper {
|
||||||
|
lateinit var context: ModContext
|
||||||
|
|
||||||
|
abstract fun useClasses(
|
||||||
|
classLoader: ClassLoader,
|
||||||
|
classes: List<Class<*>>,
|
||||||
|
mappings: MutableMap<String, Any>
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,29 @@
|
|||||||
|
package me.rhunk.snapenhance.mapping.impl
|
||||||
|
|
||||||
|
import me.rhunk.snapenhance.Logger.debug
|
||||||
|
import me.rhunk.snapenhance.mapping.Mapper
|
||||||
|
import java.lang.reflect.Method
|
||||||
|
import java.lang.reflect.Modifier
|
||||||
|
|
||||||
|
class CallbackMapper : Mapper() {
|
||||||
|
override fun useClasses(
|
||||||
|
classLoader: ClassLoader,
|
||||||
|
classes: List<Class<*>>,
|
||||||
|
mappings: MutableMap<String, Any>
|
||||||
|
) {
|
||||||
|
val callbackMappings = HashMap<String, String>()
|
||||||
|
classes.forEach { clazz ->
|
||||||
|
val superClass = clazz.superclass ?: return@forEach
|
||||||
|
if (!superClass.name.endsWith("Callback") || superClass.name.endsWith("\$Callback")) return@forEach
|
||||||
|
if (!Modifier.isAbstract(superClass.modifiers)) return@forEach
|
||||||
|
|
||||||
|
if (superClass.declaredMethods.any { method: Method ->
|
||||||
|
method.name == "onError"
|
||||||
|
}) {
|
||||||
|
callbackMappings[superClass.simpleName] = clazz.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debug("found " + callbackMappings.size + " callbacks")
|
||||||
|
mappings["callbacks"] = callbackMappings
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,41 @@
|
|||||||
|
package me.rhunk.snapenhance.mapping.impl
|
||||||
|
|
||||||
|
import me.rhunk.snapenhance.Logger.debug
|
||||||
|
import me.rhunk.snapenhance.mapping.Mapper
|
||||||
|
import java.lang.reflect.Method
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
|
||||||
|
class EnumMapper : Mapper() {
|
||||||
|
override fun useClasses(
|
||||||
|
classLoader: ClassLoader,
|
||||||
|
classes: List<Class<*>>,
|
||||||
|
mappings: MutableMap<String, Any>
|
||||||
|
) {
|
||||||
|
val enumMappings = HashMap<String, String>()
|
||||||
|
//settings classes have an interface that extends Serializable and contains the getName method
|
||||||
|
//this enum classes are used to store the settings values
|
||||||
|
//Setting enum class -> implements an interface -> getName method
|
||||||
|
classes.forEach { clazz ->
|
||||||
|
if (!clazz.isEnum) return@forEach
|
||||||
|
if (clazz.interfaces.isEmpty()) return@forEach
|
||||||
|
val serializableInterfaceClass = clazz.interfaces[0]
|
||||||
|
if (serializableInterfaceClass.methods
|
||||||
|
.filter { method: Method -> method.declaringClass == serializableInterfaceClass }
|
||||||
|
.none { method: Method -> method.name == "getName" }
|
||||||
|
) return@forEach
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
val getEnumNameMethod =
|
||||||
|
serializableInterfaceClass.methods.first { it!!.returnType.isEnum }
|
||||||
|
clazz.enumConstants?.onEach { enumConstant ->
|
||||||
|
val enumName =
|
||||||
|
Objects.requireNonNull(getEnumNameMethod.invoke(enumConstant)).toString()
|
||||||
|
enumMappings[enumName] = clazz.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debug("found " + enumMappings.size + " enums")
|
||||||
|
mappings["enums"] = enumMappings
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,77 @@
|
|||||||
|
package me.rhunk.snapenhance.mapping.impl
|
||||||
|
|
||||||
|
import me.rhunk.snapenhance.mapping.Mapper
|
||||||
|
import me.rhunk.snapenhance.util.ReflectionHelper
|
||||||
|
import java.lang.reflect.Field
|
||||||
|
import java.lang.reflect.Method
|
||||||
|
import java.lang.reflect.Modifier
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
|
||||||
|
class OperaPageViewControllerMapper : Mapper() {
|
||||||
|
override fun useClasses(
|
||||||
|
classLoader: ClassLoader,
|
||||||
|
classes: List<Class<*>>,
|
||||||
|
mappings: MutableMap<String, Any>
|
||||||
|
) {
|
||||||
|
var operaPageViewControllerClass: Class<*>? = null
|
||||||
|
for (aClass in classes) {
|
||||||
|
if (!Modifier.isAbstract(aClass.modifiers)) continue
|
||||||
|
if (aClass.interfaces.isEmpty()) continue
|
||||||
|
val foundFields = Arrays.stream(aClass.declaredFields).filter { field: Field ->
|
||||||
|
val modifiers = field.modifiers
|
||||||
|
Modifier.isStatic(modifiers) && Modifier.isFinal(
|
||||||
|
modifiers
|
||||||
|
)
|
||||||
|
}.filter { field: Field ->
|
||||||
|
try {
|
||||||
|
return@filter "ad_product_type" == String.format("%s", field[null])
|
||||||
|
} catch (e: IllegalAccessException) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}.count()
|
||||||
|
if (foundFields == 0L) continue
|
||||||
|
operaPageViewControllerClass = aClass
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (operaPageViewControllerClass == null) throw RuntimeException("OperaPageViewController not found")
|
||||||
|
|
||||||
|
val members = HashMap<String, String>()
|
||||||
|
members["Class"] = operaPageViewControllerClass.name
|
||||||
|
|
||||||
|
operaPageViewControllerClass.fields.forEach { field ->
|
||||||
|
val fieldType = field.type
|
||||||
|
if (fieldType.isEnum) {
|
||||||
|
fieldType.enumConstants.firstOrNull { enumConstant: Any -> enumConstant.toString() == "FULLY_DISPLAYED" }
|
||||||
|
.let { members["viewStateField"] = field.name }
|
||||||
|
}
|
||||||
|
if (fieldType == ArrayList::class.java) {
|
||||||
|
members["layerListField"] = field.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val enumViewStateClass = operaPageViewControllerClass.fields.first { field: Field ->
|
||||||
|
field.name == members["viewStateField"]
|
||||||
|
}.type
|
||||||
|
|
||||||
|
//find the method that call the onDisplayStateChange method
|
||||||
|
members["onDisplayStateChange"] =
|
||||||
|
operaPageViewControllerClass.methods.first { method: Method ->
|
||||||
|
if (method.returnType != Void.TYPE || method.parameterTypes.size != 1) return@first false
|
||||||
|
val firstParameterClass = method.parameterTypes[0]
|
||||||
|
//check if the class contains a field with the enumViewStateClass type
|
||||||
|
ReflectionHelper.searchFieldByType(firstParameterClass, enumViewStateClass) != null
|
||||||
|
}.name
|
||||||
|
|
||||||
|
//find the method that call the onDisplayStateChange method from gestures
|
||||||
|
members["onDisplayStateChange2"] =
|
||||||
|
operaPageViewControllerClass.methods.first { method: Method ->
|
||||||
|
if (method.returnType != Void.TYPE || method.parameterTypes.size != 2) return@first false
|
||||||
|
val firstParameterClass = method.parameterTypes[0]
|
||||||
|
val secondParameterClass = method.parameterTypes[1]
|
||||||
|
firstParameterClass.isEnum && secondParameterClass.isEnum
|
||||||
|
}.name
|
||||||
|
|
||||||
|
mappings["OperaPageViewController"] = members
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,32 @@
|
|||||||
|
package me.rhunk.snapenhance.mapping.impl
|
||||||
|
|
||||||
|
import me.rhunk.snapenhance.Logger
|
||||||
|
import me.rhunk.snapenhance.mapping.Mapper
|
||||||
|
import java.lang.reflect.Field
|
||||||
|
import java.lang.reflect.Method
|
||||||
|
|
||||||
|
|
||||||
|
class PlusSubscriptionMapper : Mapper() {
|
||||||
|
override fun useClasses(
|
||||||
|
classLoader: ClassLoader,
|
||||||
|
classes: List<Class<*>>,
|
||||||
|
mappings: MutableMap<String, Any>
|
||||||
|
) {
|
||||||
|
//find a method that contains annotations with isSubscribed
|
||||||
|
val loadSubscriptionMethod = context.classCache.composerLocalSubscriptionStore.declaredMethods.first { method: Method ->
|
||||||
|
val returnType = method.returnType
|
||||||
|
returnType.declaredFields.any { field: Field ->
|
||||||
|
field.declaredAnnotations.any { annotation: Annotation ->
|
||||||
|
annotation.toString().contains("isSubscribed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//get the first param of the method which is the PlusSubscriptionState class
|
||||||
|
val plusSubscriptionStateClass = loadSubscriptionMethod.parameterTypes[0]
|
||||||
|
//get the first param of the constructor of PlusSubscriptionState which is the SubscriptionInfo class
|
||||||
|
val subscriptionInfoClass = plusSubscriptionStateClass.constructors[0].parameterTypes[0]
|
||||||
|
Logger.debug("subscriptionInfoClass ${subscriptionInfoClass.name}")
|
||||||
|
|
||||||
|
mappings["SubscriptionInfoClass"] = subscriptionInfoClass.name
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,93 @@
|
|||||||
|
package me.rhunk.snapenhance.util
|
||||||
|
|
||||||
|
import de.robv.android.xposed.XC_MethodHook
|
||||||
|
import me.rhunk.snapenhance.hook.HookAdapter
|
||||||
|
import me.rhunk.snapenhance.hook.HookStage
|
||||||
|
import me.rhunk.snapenhance.hook.Hooker
|
||||||
|
import java.lang.reflect.Constructor
|
||||||
|
import java.lang.reflect.Field
|
||||||
|
import java.lang.reflect.Modifier
|
||||||
|
|
||||||
|
class CallbackBuilder(
|
||||||
|
private val callbackClass: Class<*>
|
||||||
|
) {
|
||||||
|
internal class Override(
|
||||||
|
val methodName: String,
|
||||||
|
val callback: (HookAdapter) -> Unit
|
||||||
|
)
|
||||||
|
|
||||||
|
private val methodOverrides = mutableListOf<Override>()
|
||||||
|
|
||||||
|
fun override(methodName: String, callback: (HookAdapter) -> Unit = {}): CallbackBuilder {
|
||||||
|
methodOverrides.add(Override(methodName, callback))
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun build(): Any {
|
||||||
|
//get the first param of the first constructor to get the class of the invoker
|
||||||
|
val invokerClass: Class<*> = callbackClass.constructors[0].parameterTypes[0]
|
||||||
|
//get the invoker field based on the invoker class
|
||||||
|
val invokerField = callbackClass.fields.first { field: Field ->
|
||||||
|
field.type.isAssignableFrom(invokerClass)
|
||||||
|
}
|
||||||
|
//get the callback field based on the callback class
|
||||||
|
val callbackInstance = createEmptyObject(callbackClass.constructors[0])!!
|
||||||
|
val callbackInstanceHashCode: Int = callbackInstance.hashCode()
|
||||||
|
val callbackInstanceClass = callbackInstance.javaClass
|
||||||
|
|
||||||
|
val unhooks = mutableListOf<XC_MethodHook.Unhook>()
|
||||||
|
|
||||||
|
callbackInstanceClass.methods.forEach { method ->
|
||||||
|
if (method.declaringClass != callbackInstanceClass) return@forEach
|
||||||
|
if (Modifier.isPrivate(method.modifiers)) return@forEach
|
||||||
|
|
||||||
|
//default hook that unhooks the callback and returns null
|
||||||
|
val defaultHook: (HookAdapter) -> Boolean = defaultHook@{
|
||||||
|
//checking invokerField ensure that's the callback was created by the CallbackBuilder
|
||||||
|
if (invokerField.get(it.thisObject()) != null) return@defaultHook false
|
||||||
|
if ((it.thisObject() as Any).hashCode() != callbackInstanceHashCode) return@defaultHook false
|
||||||
|
|
||||||
|
it.setResult(null)
|
||||||
|
unhooks.forEach { unhook -> unhook.unhook() }
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
var hook: (HookAdapter) -> Unit = { defaultHook(it) }
|
||||||
|
|
||||||
|
//override the default hook if the method is in the override list
|
||||||
|
methodOverrides.find { it.methodName == method.name }?.run {
|
||||||
|
hook = {
|
||||||
|
if (defaultHook(it)) {
|
||||||
|
callback(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unhooks.add(Hooker.hook(method, HookStage.BEFORE, hook))
|
||||||
|
}
|
||||||
|
return callbackInstance
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private fun createEmptyObject(constructor: Constructor<*>): Any? {
|
||||||
|
//compute the args for the constructor with null or default primitive values
|
||||||
|
val args = constructor.parameterTypes.map { type: Class<*> ->
|
||||||
|
if (type.isPrimitive) {
|
||||||
|
when (type.name) {
|
||||||
|
"boolean" -> return@map false
|
||||||
|
"byte" -> return@map 0.toByte()
|
||||||
|
"char" -> return@map 0.toChar()
|
||||||
|
"short" -> return@map 0.toShort()
|
||||||
|
"int" -> return@map 0
|
||||||
|
"long" -> return@map 0L
|
||||||
|
"float" -> return@map 0f
|
||||||
|
"double" -> return@map 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
null
|
||||||
|
}.toTypedArray()
|
||||||
|
return constructor.newInstance(*args)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,67 @@
|
|||||||
|
package me.rhunk.snapenhance.util
|
||||||
|
|
||||||
|
import me.rhunk.snapenhance.Constants
|
||||||
|
import me.rhunk.snapenhance.data.ContentType
|
||||||
|
import me.rhunk.snapenhance.util.protobuf.ProtoReader
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.util.*
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.CipherInputStream
|
||||||
|
import javax.crypto.spec.IvParameterSpec
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
|
||||||
|
object EncryptionUtils {
|
||||||
|
fun decryptInputStreamFromArroyo(
|
||||||
|
inputStream: InputStream,
|
||||||
|
contentType: ContentType,
|
||||||
|
messageProto: ProtoReader
|
||||||
|
): InputStream {
|
||||||
|
var resultInputStream = inputStream
|
||||||
|
val encryptionProtoPath: IntArray = when (contentType) {
|
||||||
|
ContentType.NOTE -> Constants.ARROYO_NOTE_ENCRYPTION_PROTO_PATH
|
||||||
|
ContentType.SNAP -> Constants.ARROYO_SNAP_ENCRYPTION_PROTO_PATH
|
||||||
|
ContentType.EXTERNAL_MEDIA -> Constants.ARROYO_EXTERNAL_MEDIA_ENCRYPTION_PROTO_PATH
|
||||||
|
else -> throw IllegalArgumentException("Invalid content type: $contentType")
|
||||||
|
}
|
||||||
|
|
||||||
|
//decrypt the content if needed
|
||||||
|
messageProto.readPath(*encryptionProtoPath)?.let {
|
||||||
|
val encryptionProtoIndex: Int = if (it.exists(Constants.ARROYO_ENCRYPTION_PROTO_INDEX_V2)) {
|
||||||
|
Constants.ARROYO_ENCRYPTION_PROTO_INDEX_V2
|
||||||
|
} else if (it.exists(Constants.ARROYO_ENCRYPTION_PROTO_INDEX)) {
|
||||||
|
Constants.ARROYO_ENCRYPTION_PROTO_INDEX
|
||||||
|
} else {
|
||||||
|
return resultInputStream
|
||||||
|
}
|
||||||
|
resultInputStream = decryptInputStream(
|
||||||
|
resultInputStream,
|
||||||
|
encryptionProtoIndex == Constants.ARROYO_ENCRYPTION_PROTO_INDEX_V2,
|
||||||
|
it,
|
||||||
|
encryptionProtoIndex
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return resultInputStream
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decryptInputStream(
|
||||||
|
inputStream: InputStream,
|
||||||
|
base64Encryption: Boolean,
|
||||||
|
mediaInfoProto: ProtoReader,
|
||||||
|
encryptionProtoIndex: Int
|
||||||
|
): InputStream {
|
||||||
|
val mediaEncryption = mediaInfoProto.readPath(encryptionProtoIndex)!!
|
||||||
|
var key: ByteArray = mediaEncryption.getByteArray(1)!!
|
||||||
|
var iv: ByteArray = mediaEncryption.getByteArray(2)!!
|
||||||
|
|
||||||
|
//audio note and external medias have their key and iv encoded in base64
|
||||||
|
if (base64Encryption) {
|
||||||
|
val decoder = Base64.getMimeDecoder()
|
||||||
|
key = decoder.decode(key)
|
||||||
|
iv = decoder.decode(iv)
|
||||||
|
}
|
||||||
|
|
||||||
|
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(iv))
|
||||||
|
return CipherInputStream(inputStream, cipher)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,41 @@
|
|||||||
|
package me.rhunk.snapenhance.util
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.media.MediaDataSource
|
||||||
|
import android.media.MediaMetadataRetriever
|
||||||
|
|
||||||
|
object PreviewUtils {
|
||||||
|
fun createPreview(data: ByteArray, isVideo: Boolean): Bitmap? {
|
||||||
|
if (!isVideo) {
|
||||||
|
return BitmapFactory.decodeByteArray(data, 0, data.size)
|
||||||
|
}
|
||||||
|
val retriever = MediaMetadataRetriever()
|
||||||
|
retriever.setDataSource(object : MediaDataSource() {
|
||||||
|
override fun readAt(
|
||||||
|
position: Long,
|
||||||
|
buffer: ByteArray,
|
||||||
|
offset: Int,
|
||||||
|
size: Int
|
||||||
|
): Int {
|
||||||
|
var newSize = size
|
||||||
|
val length = data.size
|
||||||
|
if (position >= length) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if (position + newSize > length) {
|
||||||
|
newSize = length - position.toInt()
|
||||||
|
}
|
||||||
|
System.arraycopy(data, position.toInt(), buffer, offset, newSize)
|
||||||
|
return newSize
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getSize(): Long {
|
||||||
|
return data.size.toLong()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {}
|
||||||
|
})
|
||||||
|
return retriever.getFrameAtTime(0, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,118 @@
|
|||||||
|
package me.rhunk.snapenhance.util
|
||||||
|
|
||||||
|
import java.lang.reflect.Field
|
||||||
|
import java.lang.reflect.Method
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
object ReflectionHelper {
|
||||||
|
/**
|
||||||
|
* Searches for a field with a class that has a method with the specified name
|
||||||
|
*/
|
||||||
|
fun searchFieldWithClassMethod(clazz: Class<*>, methodName: String): Field? {
|
||||||
|
return clazz.declaredFields.firstOrNull { f: Field? ->
|
||||||
|
try {
|
||||||
|
return@firstOrNull Arrays.stream(
|
||||||
|
f!!.type.declaredMethods
|
||||||
|
).anyMatch { method: Method -> method.name == methodName }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return@firstOrNull false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun searchFieldByType(clazz: Class<*>, type: Class<*>): Field? {
|
||||||
|
return clazz.declaredFields.firstOrNull { f: Field? -> f!!.type == type }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun searchFieldTypeInSuperClasses(clazz: Class<*>, type: Class<*>): Field? {
|
||||||
|
val field = searchFieldByType(clazz, type)
|
||||||
|
if (field != null) {
|
||||||
|
return field
|
||||||
|
}
|
||||||
|
val superclass = clazz.superclass
|
||||||
|
return superclass?.let { searchFieldTypeInSuperClasses(it, type) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun searchFieldStartsWithToString(
|
||||||
|
clazz: Class<*>,
|
||||||
|
instance: Any,
|
||||||
|
toString: String?
|
||||||
|
): Field? {
|
||||||
|
return clazz.declaredFields.firstOrNull { f: Field ->
|
||||||
|
try {
|
||||||
|
f.isAccessible = true
|
||||||
|
return@firstOrNull Objects.requireNonNull(f[instance]).toString()
|
||||||
|
.startsWith(
|
||||||
|
toString!!
|
||||||
|
)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
return@firstOrNull false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun searchFieldContainsToString(
|
||||||
|
clazz: Class<*>,
|
||||||
|
instance: Any?,
|
||||||
|
toString: String?
|
||||||
|
): Field? {
|
||||||
|
return clazz.declaredFields.firstOrNull { f: Field ->
|
||||||
|
try {
|
||||||
|
f.isAccessible = true
|
||||||
|
return@firstOrNull Objects.requireNonNull(f[instance]).toString()
|
||||||
|
.contains(toString!!)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
return@firstOrNull false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun searchFirstFieldTypeInClassRecursive(clazz: Class<*>, type: Class<*>): Field? {
|
||||||
|
return clazz.declaredFields.firstOrNull {
|
||||||
|
val field = searchFieldByType(it.type, type)
|
||||||
|
return@firstOrNull field != null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searches for a field with a class that has a method with the specified return type
|
||||||
|
*/
|
||||||
|
fun searchMethodWithReturnType(clazz: Class<*>, returnType: Class<*>): Method? {
|
||||||
|
return clazz.declaredMethods.first { m: Method -> m.returnType == returnType }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searches for a field with a class that has a method with the specified return type and parameter types
|
||||||
|
*/
|
||||||
|
fun searchMethodWithParameterAndReturnType(
|
||||||
|
aClass: Class<*>,
|
||||||
|
returnType: Class<*>,
|
||||||
|
vararg parameters: Class<*>
|
||||||
|
): Method? {
|
||||||
|
return aClass.declaredMethods.firstOrNull { m: Method ->
|
||||||
|
if (m.returnType != returnType) {
|
||||||
|
return@firstOrNull false
|
||||||
|
}
|
||||||
|
val parameterTypes = m.parameterTypes
|
||||||
|
if (parameterTypes.size != parameters.size) {
|
||||||
|
return@firstOrNull false
|
||||||
|
}
|
||||||
|
for (i in parameterTypes.indices) {
|
||||||
|
if (parameterTypes[i] != parameters[i]) {
|
||||||
|
return@firstOrNull false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDeclaredFieldsRecursively(clazz: Class<*>): List<Field> {
|
||||||
|
val fields = clazz.declaredFields.toMutableList()
|
||||||
|
val superclass = clazz.superclass
|
||||||
|
if (superclass != null) {
|
||||||
|
fields.addAll(getDeclaredFieldsRecursively(superclass))
|
||||||
|
}
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,20 @@
|
|||||||
|
package me.rhunk.snapenhance.util
|
||||||
|
|
||||||
|
import de.robv.android.xposed.XposedHelpers
|
||||||
|
|
||||||
|
fun Any.getObjectField(fieldName: String): Any {
|
||||||
|
return XposedHelpers.getObjectField(this, fieldName)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Any.setObjectField(fieldName: String, value: Any) {
|
||||||
|
XposedHelpers.setObjectField(this, fieldName, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Any.getObjectFieldOrNull(fieldName: String): Any? {
|
||||||
|
return try {
|
||||||
|
getObjectField(fieldName)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,83 @@
|
|||||||
|
package me.rhunk.snapenhance.util.download
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import me.rhunk.snapenhance.Constants
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.net.URL
|
||||||
|
import javax.net.ssl.HttpsURLConnection
|
||||||
|
|
||||||
|
object CdnDownloader {
|
||||||
|
const val BOLT_CDN_U = "https://bolt-gcdn.sc-cdn.net/u/"
|
||||||
|
const val BOLT_CDN_X = "https://bolt-gcdn.sc-cdn.net/x/"
|
||||||
|
const val CF_ST_CDN_D = "https://cf-st.sc-cdn.net/d/"
|
||||||
|
const val CF_ST_CDN_F = "https://cf-st.sc-cdn.net/f/"
|
||||||
|
const val CF_ST_CDN_H = "https://cf-st.sc-cdn.net/h/"
|
||||||
|
const val CF_ST_CDN_G = "https://cf-st.sc-cdn.net/g/"
|
||||||
|
const val CF_ST_CDN_O = "https://cf-st.sc-cdn.net/o/"
|
||||||
|
const val CF_ST_CDN_I = "https://cf-st.sc-cdn.net/i/"
|
||||||
|
const val CF_ST_CDN_J = "https://cf-st.sc-cdn.net/j/"
|
||||||
|
const val CF_ST_CDN_C = "https://cf-st.sc-cdn.net/c/"
|
||||||
|
const val CF_ST_CDN_AA = "https://cf-st.sc-cdn.net/aa/"
|
||||||
|
|
||||||
|
private val keyCache: MutableMap<String, String> = mutableMapOf()
|
||||||
|
|
||||||
|
fun downloadRemoteContent(
|
||||||
|
key: String,
|
||||||
|
vararg endpoints: String
|
||||||
|
): InputStream? = runBlocking {
|
||||||
|
if (keyCache.containsKey(key)) {
|
||||||
|
return@runBlocking queryRemoteContent(
|
||||||
|
keyCache[key]!!
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val jobs = mutableListOf<Job>()
|
||||||
|
var inputStream: InputStream? = null
|
||||||
|
|
||||||
|
endpoints.forEach {
|
||||||
|
launch {
|
||||||
|
val url = it + key
|
||||||
|
val result = queryRemoteContent(url)
|
||||||
|
if (result != null) {
|
||||||
|
keyCache[key] = url
|
||||||
|
inputStream = result
|
||||||
|
jobs.forEach { it.cancel() }
|
||||||
|
}
|
||||||
|
}.also { jobs.add(it) }
|
||||||
|
}
|
||||||
|
jobs.forEach { it.join() }
|
||||||
|
inputStream
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private fun queryRemoteContent(url: String): InputStream? {
|
||||||
|
try {
|
||||||
|
val connection = URL(url).openConnection() as HttpsURLConnection
|
||||||
|
connection.requestMethod = "GET"
|
||||||
|
connection.connectTimeout = 5000
|
||||||
|
connection.setRequestProperty("User-Agent", Constants.USER_AGENT)
|
||||||
|
return connection.inputStream
|
||||||
|
} catch (ignored: Throwable) {
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
//TODO: automatically detect the correct endpoint
|
||||||
|
fun downloadWithDefaultEndpoints(key: String): InputStream? {
|
||||||
|
return downloadRemoteContent(
|
||||||
|
key,
|
||||||
|
CF_ST_CDN_F,
|
||||||
|
CF_ST_CDN_H,
|
||||||
|
BOLT_CDN_U,
|
||||||
|
BOLT_CDN_X,
|
||||||
|
CF_ST_CDN_O,
|
||||||
|
CF_ST_CDN_I,
|
||||||
|
CF_ST_CDN_C,
|
||||||
|
CF_ST_CDN_J,
|
||||||
|
CF_ST_CDN_AA,
|
||||||
|
CF_ST_CDN_G,
|
||||||
|
CF_ST_CDN_D
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,116 @@
|
|||||||
|
package me.rhunk.snapenhance.util.download
|
||||||
|
|
||||||
|
import de.robv.android.xposed.XposedBridge
|
||||||
|
import me.rhunk.snapenhance.Logger
|
||||||
|
import me.rhunk.snapenhance.Logger.debug
|
||||||
|
import me.rhunk.snapenhance.ModContext
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.File
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
import java.io.PrintWriter
|
||||||
|
import java.net.ServerSocket
|
||||||
|
import java.net.Socket
|
||||||
|
import java.util.*
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.ThreadLocalRandom
|
||||||
|
import java.util.function.Consumer
|
||||||
|
|
||||||
|
class DownloadServer(
|
||||||
|
private val context: ModContext
|
||||||
|
) {
|
||||||
|
private val port = ThreadLocalRandom.current().nextInt(10000, 65535)
|
||||||
|
|
||||||
|
private val cachedData = ConcurrentHashMap<String, ByteArray>()
|
||||||
|
private var serverSocket: ServerSocket? = null
|
||||||
|
|
||||||
|
fun startFileDownload(destination: File, content: ByteArray, callback: Consumer<Boolean>) {
|
||||||
|
val httpKey = java.lang.Long.toHexString(System.nanoTime())
|
||||||
|
ensureServerStarted {
|
||||||
|
putDownloadableContent(httpKey, content)
|
||||||
|
val url = "http://127.0.0.1:$port/$httpKey"
|
||||||
|
context.executeAsync {
|
||||||
|
val result: Boolean = context.bridgeClient.downloadContent(url, destination.absolutePath)
|
||||||
|
callback.accept(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureServerStarted(callback: Runnable) {
|
||||||
|
if (serverSocket != null && !serverSocket!!.isClosed) {
|
||||||
|
callback.run()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Thread {
|
||||||
|
try {
|
||||||
|
debug("started web server on 127.0.0.1:$port")
|
||||||
|
serverSocket = ServerSocket(port)
|
||||||
|
callback.run()
|
||||||
|
while (!serverSocket!!.isClosed) {
|
||||||
|
try {
|
||||||
|
val socket = serverSocket!!.accept()
|
||||||
|
Thread { handleRequest(socket) }.start()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.xposedLog(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.xposedLog(e)
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun putDownloadableContent(key: String, data: ByteArray) {
|
||||||
|
cachedData[key] = data
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleRequest(socket: Socket) {
|
||||||
|
val reader = BufferedReader(InputStreamReader(socket.getInputStream()))
|
||||||
|
val outputStream = socket.getOutputStream()
|
||||||
|
val writer = PrintWriter(outputStream)
|
||||||
|
val line = reader.readLine() ?: return
|
||||||
|
val close = Runnable {
|
||||||
|
try {
|
||||||
|
reader.close()
|
||||||
|
writer.close()
|
||||||
|
outputStream.close()
|
||||||
|
socket.close()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.xposedLog(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val parse = StringTokenizer(line)
|
||||||
|
val method = parse.nextToken().uppercase(Locale.getDefault())
|
||||||
|
var fileRequested = parse.nextToken().lowercase(Locale.getDefault())
|
||||||
|
if (method != "GET") {
|
||||||
|
writer.println("HTTP/1.1 501 Not Implemented")
|
||||||
|
writer.println("Content-type: " + "application/octet-stream")
|
||||||
|
writer.println("Content-length: " + 0)
|
||||||
|
writer.println()
|
||||||
|
writer.flush()
|
||||||
|
close.run()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (fileRequested.startsWith("/")) {
|
||||||
|
fileRequested = fileRequested.substring(1)
|
||||||
|
}
|
||||||
|
if (!cachedData.containsKey(fileRequested)) {
|
||||||
|
writer.println("HTTP/1.1 404 Not Found")
|
||||||
|
writer.println("Content-type: " + "application/octet-stream")
|
||||||
|
writer.println("Content-length: " + 0)
|
||||||
|
writer.println()
|
||||||
|
writer.flush()
|
||||||
|
close.run()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val data = cachedData[fileRequested]!!
|
||||||
|
writer.println("HTTP/1.1 200 OK")
|
||||||
|
writer.println("Content-type: " + "application/octet-stream")
|
||||||
|
writer.println("Content-length: " + data.size)
|
||||||
|
writer.println()
|
||||||
|
writer.flush()
|
||||||
|
outputStream.write(data, 0, data.size)
|
||||||
|
outputStream.flush()
|
||||||
|
close.run()
|
||||||
|
cachedData.remove(fileRequested)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,122 @@
|
|||||||
|
package me.rhunk.snapenhance.util.protobuf
|
||||||
|
|
||||||
|
data class Wire(val type: Int, val value: Any)
|
||||||
|
|
||||||
|
class ProtoReader(private val buffer: ByteArray) {
|
||||||
|
private var offset: Int = 0
|
||||||
|
private val values = mutableMapOf<Int, MutableList<Wire>>()
|
||||||
|
|
||||||
|
init {
|
||||||
|
read()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getBuffer() = buffer
|
||||||
|
|
||||||
|
private fun readByte() = buffer[offset++]
|
||||||
|
|
||||||
|
private fun readVarInt(): Long {
|
||||||
|
var result = 0L
|
||||||
|
var shift = 0
|
||||||
|
while (true) {
|
||||||
|
val b = readByte()
|
||||||
|
result = result or ((b.toLong() and 0x7F) shl shift)
|
||||||
|
if (b.toInt() and 0x80 == 0) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
shift += 7
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun read() {
|
||||||
|
while (offset < buffer.size) {
|
||||||
|
val tag = readVarInt().toInt()
|
||||||
|
val id = tag ushr 3
|
||||||
|
val type = tag and 0x7
|
||||||
|
try {
|
||||||
|
val value = when (type) {
|
||||||
|
0 -> readVarInt().toString().toByteArray()
|
||||||
|
2 -> {
|
||||||
|
val length = readVarInt().toInt()
|
||||||
|
val value = buffer.copyOfRange(offset, offset + length)
|
||||||
|
offset += length
|
||||||
|
value
|
||||||
|
}
|
||||||
|
else -> break
|
||||||
|
}
|
||||||
|
values.getOrPut(id) { mutableListOf() }.add(Wire(type, value))
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
values.clear()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun readPath(vararg ids: Int, reader: (ProtoReader.() -> Unit)? = null): ProtoReader? {
|
||||||
|
var thisReader = this
|
||||||
|
ids.forEach { id ->
|
||||||
|
if (!thisReader.exists(id)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
thisReader = ProtoReader(thisReader.get(id) as ByteArray)
|
||||||
|
}
|
||||||
|
if (reader != null) {
|
||||||
|
thisReader.reader()
|
||||||
|
}
|
||||||
|
return thisReader
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pathExists(vararg ids: Int): Boolean {
|
||||||
|
var thisReader = this
|
||||||
|
ids.forEach { id ->
|
||||||
|
if (!thisReader.exists(id)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
thisReader = ProtoReader(thisReader.get(id) as ByteArray)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getByteArray(id: Int) = values[id]?.first()?.value as ByteArray?
|
||||||
|
fun getByteArray(vararg ids: Int): ByteArray? {
|
||||||
|
if (ids.isEmpty() || ids.size < 2) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val lastId = ids.last()
|
||||||
|
var value: ByteArray? = null
|
||||||
|
readPath(*(ids.copyOfRange(0, ids.size - 1))) {
|
||||||
|
value = getByteArray(lastId)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getString(id: Int) = getByteArray(id)?.toString(Charsets.UTF_8)
|
||||||
|
fun getString(vararg ids: Int) = getByteArray(*ids)?.toString(Charsets.UTF_8)
|
||||||
|
|
||||||
|
fun getInt(id: Int) = getString(id)?.toInt()
|
||||||
|
fun getInt(vararg ids: Int) = getString(*ids)?.toInt()
|
||||||
|
|
||||||
|
fun getLong(id: Int) = getString(id)?.toLong()
|
||||||
|
fun getLong(vararg ids: Int) = getString(*ids)?.toLong()
|
||||||
|
|
||||||
|
fun exists(id: Int) = values.containsKey(id)
|
||||||
|
|
||||||
|
fun get(id: Int) = values[id]!!.first().value
|
||||||
|
|
||||||
|
fun isValid() = values.isNotEmpty()
|
||||||
|
|
||||||
|
fun getCount(id: Int) = values[id]!!.size
|
||||||
|
|
||||||
|
fun each(id: Int, reader: ProtoReader.(index: Int) -> Unit) {
|
||||||
|
values[id]!!.forEachIndexed { index, _ ->
|
||||||
|
ProtoReader(values[id]!![index].value as ByteArray).reader(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun eachExists(id: Int, reader: ProtoReader.(index: Int) -> Unit) {
|
||||||
|
if (!exists(id)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
each(id, reader)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,66 @@
|
|||||||
|
package me.rhunk.snapenhance.util.protobuf
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
|
||||||
|
class ProtoWriter {
|
||||||
|
private val stream: ByteArrayOutputStream = ByteArrayOutputStream()
|
||||||
|
|
||||||
|
private fun writeVarInt(value: Int) {
|
||||||
|
var v = value
|
||||||
|
while (v and -0x80 != 0) {
|
||||||
|
stream.write(v and 0x7F or 0x80)
|
||||||
|
v = v ushr 7
|
||||||
|
}
|
||||||
|
stream.write(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeVarLong(value: Long) {
|
||||||
|
var v = value
|
||||||
|
while (v and -0x80L != 0L) {
|
||||||
|
stream.write((v and 0x7FL or 0x80L).toInt())
|
||||||
|
v = v ushr 7
|
||||||
|
}
|
||||||
|
stream.write(v.toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeBuffer(id: Int, value: ByteArray) {
|
||||||
|
writeVarInt(id shl 3 or 2)
|
||||||
|
writeVarInt(value.size)
|
||||||
|
stream.write(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeConstant(id: Int, value: Int) {
|
||||||
|
writeVarInt(id shl 3)
|
||||||
|
writeVarInt(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeConstant(id: Int, value: Long) {
|
||||||
|
writeVarInt(id shl 3)
|
||||||
|
writeVarLong(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeString(id: Int, value: String) = writeBuffer(id, value.toByteArray())
|
||||||
|
|
||||||
|
fun write(id: Int, writer: ProtoWriter.() -> Unit) {
|
||||||
|
val writerStream = ProtoWriter()
|
||||||
|
writer(writerStream)
|
||||||
|
writeBuffer(id, writerStream.stream.toByteArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun write(vararg ids: Int, writer: ProtoWriter.() -> Unit) {
|
||||||
|
val writerStream = ProtoWriter()
|
||||||
|
writer(writerStream)
|
||||||
|
var stream = writerStream.stream.toByteArray()
|
||||||
|
ids.reversed().forEach { id ->
|
||||||
|
with(ProtoWriter()) {
|
||||||
|
writeBuffer(id, stream)
|
||||||
|
stream = this.stream.toByteArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stream.let(this.stream::write)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toByteArray(): ByteArray {
|
||||||
|
return stream.toByteArray()
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,2 @@
|
|||||||
|
package me.rhunk.snapenhance.util.snap
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user