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