Merge remote-tracking branch 'upstream'
This commit is contained in:
commit
bfa2757156
32 changed files with 1075 additions and 501 deletions
53
.github/workflows/build.yml
vendored
53
.github/workflows/build.yml
vendored
|
|
@ -1,53 +0,0 @@
|
|||
name: Build APK
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build and push
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
|
||||
- name: "Setup Gradle"
|
||||
uses: gradle/gradle-build-action@v2
|
||||
|
||||
- name: "Grant execution permission"
|
||||
run: chmod +x ./gradlew
|
||||
|
||||
- name: "Build APKs"
|
||||
run: ./gradlew build
|
||||
|
||||
- name: "Setup directories"
|
||||
run: |
|
||||
mkdir -p /tmp/pupubuild/apks
|
||||
mkdir -p /tmp/pupubuild/tools
|
||||
|
||||
- name: "Move debug APK"
|
||||
run: mv ./app/build/outputs/apk/debug/app-debug.apk /tmp/pupubuild/apks
|
||||
|
||||
- name: "Download uber-apk-signer"
|
||||
run: wget -nv "https://github.com/patrickfav/uber-apk-signer/releases/download/v1.2.1/uber-apk-signer-1.2.1.jar" -O /tmp/pupubuild/tools/uber-apk-signer.jar
|
||||
|
||||
- name: "Sign release APK"
|
||||
run: java -jar /tmp/pupubuild/tools/uber-apk-signer.jar --apks ./app/build/outputs/apk/release/app-release-unsigned.apk --out /tmp/pupubuild/apks/
|
||||
|
||||
- name: "Rename release APK"
|
||||
run: mv /tmp/pupubuild/apks/app-release-aligned-debugSigned.apk /tmp/pupubuild/apks/app-release.apk
|
||||
|
||||
- name: "Upload debug APK"
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: app-debug
|
||||
path: /tmp/pupubuild/apks/app-debug.apk
|
||||
|
||||
- name: "Upload release APK"
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: app-release
|
||||
path: /tmp/pupubuild/apks/app-release.apk
|
||||
77
.github/workflows/release.yml
vendored
77
.github/workflows/release.yml
vendored
|
|
@ -1,77 +0,0 @@
|
|||
name: Build Release
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
versionName:
|
||||
required: true
|
||||
description: This releases version name
|
||||
default: "1.0.0"
|
||||
versionCode:
|
||||
required: true
|
||||
description: This releases version code
|
||||
default: "1000"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@master
|
||||
|
||||
- name: Setup JDK 17
|
||||
uses: actions/setup-java@v2
|
||||
with:
|
||||
java-version: 17
|
||||
distribution: 'temurin'
|
||||
cache: 'gradle'
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v2.0.10
|
||||
|
||||
- name: Set Version
|
||||
uses: chkfung/android-version-actions@v1.1
|
||||
with:
|
||||
gradlePath: app/build.gradle.kts
|
||||
versionCode: ${{github.event.inputs.versionCode}}
|
||||
versionName: ${{github.event.inputs.versionName}}
|
||||
|
||||
- name: Bump Version
|
||||
continue-on-error: true
|
||||
run: |
|
||||
git config --local user.email "actions@github.com"
|
||||
git config --local user.name "GitHub Actions"
|
||||
git add app/build.gradle.kts
|
||||
if ! git diff --quiet app/build.gradle.kts; then
|
||||
git commit -m "chore: Bump version to ${{github.event.inputs.versionName}} (${{github.event.inputs.versionCode}})"
|
||||
git push
|
||||
else
|
||||
echo "No changes to version file, skipping commit"
|
||||
fi
|
||||
|
||||
- name: Build Signed APK
|
||||
run: |
|
||||
echo "${{ secrets.keystore }}" | base64 -d > $GITHUB_WORKSPACE/signing-key.jks
|
||||
chmod +x ./gradlew
|
||||
./gradlew packageReleaseUniversalApk \
|
||||
-Pandroid.injected.signing.store.file=$GITHUB_WORKSPACE/signing-key.jks \
|
||||
-Pandroid.injected.signing.store.password=${{ secrets.keystore_password }} \
|
||||
-Pandroid.injected.signing.key.alias=${{ secrets.key_alias }} \
|
||||
-Pandroid.injected.signing.key.password=${{ secrets.key_password }}
|
||||
|
||||
- name: Release
|
||||
run: |
|
||||
mv app/build/outputs/apk_from_bundle/release/app-release-universal.apk ./app-release.apk
|
||||
git config --local user.email "actions@github.com"
|
||||
git config --local user.name "GitHub Actions"
|
||||
tag="${{ github.event.inputs.versionCode }}"
|
||||
git tag "$tag"
|
||||
git push origin "$tag"
|
||||
gh release create "$tag" \
|
||||
--title "${{ github.event.inputs.versionName }}" \
|
||||
--generate-notes \
|
||||
./*.apk
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -13,4 +13,3 @@ build
|
|||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
app/release/release/
|
||||
|
|
|
|||
1
.idea/.name
generated
1
.idea/.name
generated
|
|
@ -1 +0,0 @@
|
|||
KettuXposed
|
||||
1
.idea/misc.xml
generated
1
.idea/misc.xml
generated
|
|
@ -1,3 +1,4 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="FrameworkDetectionExcludesConfiguration">
|
||||
|
|
|
|||
|
|
@ -12,27 +12,27 @@ android {
|
|||
applicationId = "cocobo1.pupu.xposed"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 1210
|
||||
versionName = "1.2.1"
|
||||
versionCode = 1405
|
||||
versionName = "1.3.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
isDebuggable = true
|
||||
isMinifyEnabled = false
|
||||
isShrinkResources = false
|
||||
applicationIdSuffix = ".debug"
|
||||
}
|
||||
release {
|
||||
isDebuggable = false
|
||||
isMinifyEnabled = false
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_21
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "21"
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
|
|
@ -45,6 +45,6 @@ dependencies {
|
|||
implementation("androidx.core:core-ktx:1.17.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
||||
implementation("io.ktor:ktor-client-core:3.3.0")
|
||||
implementation("io.ktor:ktor-client-cio:3.3.0")
|
||||
implementation("io.ktor:ktor-client-core:3.3.1")
|
||||
implementation("io.ktor:ktor-client-cio:3.3.1")
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,37 +0,0 @@
|
|||
{
|
||||
"version": 3,
|
||||
"artifactType": {
|
||||
"type": "APK",
|
||||
"kind": "Directory"
|
||||
},
|
||||
"applicationId": "cocobo1.pupu.xposed",
|
||||
"variantName": "release",
|
||||
"elements": [
|
||||
{
|
||||
"type": "SINGLE",
|
||||
"filters": [],
|
||||
"attributes": [],
|
||||
"versionCode": 1200,
|
||||
"versionName": "1.2.0",
|
||||
"outputFile": "app-release.apk"
|
||||
}
|
||||
],
|
||||
"elementType": "File",
|
||||
"baselineProfiles": [
|
||||
{
|
||||
"minApi": 28,
|
||||
"maxApi": 30,
|
||||
"baselineProfiles": [
|
||||
"baselineProfiles/1/app-release.dm"
|
||||
]
|
||||
},
|
||||
{
|
||||
"minApi": 31,
|
||||
"maxApi": 2147483647,
|
||||
"baselineProfiles": [
|
||||
"baselineProfiles/0/app-release.dm"
|
||||
]
|
||||
}
|
||||
],
|
||||
"minSdkVersionForDexing": 24
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
android:value="true"/>
|
||||
<meta-data
|
||||
android:name="xposeddescription"
|
||||
android:value="Xposed module to inject Kettu into discord"/>
|
||||
android:value="an Xposed module to inject Kettu into discord"/>
|
||||
<meta-data
|
||||
android:name="xposedminversion"
|
||||
android:value="82"/>
|
||||
|
|
|
|||
|
|
@ -7,12 +7,16 @@ import android.os.Bundle
|
|||
import de.robv.android.xposed.IXposedHookLoadPackage
|
||||
import de.robv.android.xposed.IXposedHookZygoteInit
|
||||
import de.robv.android.xposed.callbacks.XC_LoadPackage
|
||||
import cocobo1.pupu.xposed.Utils.Log
|
||||
import cocobo1.pupu.xposed.modules.*
|
||||
import cocobo1.pupu.xposed.modules.appearance.FontsModule
|
||||
import cocobo1.pupu.xposed.modules.appearance.SysColorsModule
|
||||
import cocobo1.pupu.xposed.modules.appearance.ThemesModule
|
||||
import cocobo1.pupu.xposed.modules.bridge.AdditionalBridgeMethodsModule
|
||||
import cocobo1.pupu.xposed.modules.bridge.BridgeModule
|
||||
import cocobo1.pupu.xposed.modules.no_track.BlockCrashReportingModule
|
||||
import cocobo1.pupu.xposed.modules.no_track.BlockDeepLinksTrackingModule
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
|
||||
object HookStateHolder {
|
||||
/**
|
||||
|
|
@ -35,11 +39,13 @@ class Main : Module(), IXposedHookLoadPackage, IXposedHookZygoteInit {
|
|||
HookScriptLoaderModule(),
|
||||
BridgeModule(),
|
||||
AdditionalBridgeMethodsModule(),
|
||||
UpdaterModule(),
|
||||
PluginsModule(),
|
||||
UpdaterModule,
|
||||
FixResourcesModule(),
|
||||
BlockDeepLinksTrackingModule(),
|
||||
BlockCrashReportingModule(),
|
||||
LogBoxModule(),
|
||||
CacheModule(),
|
||||
FontsModule(),
|
||||
ThemesModule(),
|
||||
SysColorsModule()
|
||||
|
|
@ -60,13 +66,25 @@ class Main : Module(), IXposedHookLoadPackage, IXposedHookZygoteInit {
|
|||
|
||||
ContextWrapper::class.java.hookMethod("attachBaseContext", Context::class.java) {
|
||||
after {
|
||||
this@Main.onContext(args[0] as Context)
|
||||
val ctx = args[0] as Context
|
||||
HookStateHolder.gotContext = true
|
||||
Log.i("Received Context")
|
||||
this@Main.onContext(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
reactActivity.hookMethod("onCreate", Bundle::class.java) {
|
||||
after {
|
||||
this@Main.onActivity(thisObject as Activity)
|
||||
val act = thisObject as Activity
|
||||
Log.i("Received Activity")
|
||||
|
||||
if (!HookStateHolder.gotContext) {
|
||||
Log.w("Activity created before we got Context, process may have been recreated!")
|
||||
this@Main.onContext(act.applicationContext)
|
||||
}
|
||||
|
||||
this@Main.onActivity(act)
|
||||
HookStateHolder.readyDeferred.complete(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,8 +1,12 @@
|
|||
package cocobo1.pupu.xposed
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.app.AndroidAppHelper
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import cocobo1.pupu.xposed.modules.UpdaterModule
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
class Utils {
|
||||
|
|
@ -15,6 +19,32 @@ class Utils {
|
|||
application.startActivity(Intent.makeRestartActivityTask(intent!!.component))
|
||||
exitProcess(0)
|
||||
}
|
||||
|
||||
fun showRecoveryAlert(context: Context) {
|
||||
AlertDialog.Builder(context).setTitle("Kettu Recovery Options")
|
||||
.setItems(arrayOf("Reload", "Delete Script", "Reset Loader Config")) { _, which ->
|
||||
when (which) {
|
||||
0 -> {
|
||||
reloadApp()
|
||||
}
|
||||
|
||||
1 -> {
|
||||
val bundleFile = File(
|
||||
context.dataDir, "${Constants.CACHE_DIR}/${Constants.MAIN_SCRIPT_FILE}"
|
||||
)
|
||||
|
||||
if (bundleFile.exists()) bundleFile.delete()
|
||||
|
||||
reloadApp()
|
||||
}
|
||||
|
||||
2 -> {
|
||||
UpdaterModule.resetLoaderConfig(context)
|
||||
reloadApp()
|
||||
}
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
}
|
||||
|
||||
object Log {
|
||||
270
app/src/main/kotlin/cocobo1/pupu/xposed/modules/CacheModule.kt
Normal file
270
app/src/main/kotlin/cocobo1/pupu/xposed/modules/CacheModule.kt
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
package cocobo1.pupu.xposed.modules
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AtomicFile
|
||||
import cocobo1.pupu.xposed.Module
|
||||
import cocobo1.pupu.xposed.Utils.Log
|
||||
import cocobo1.pupu.xposed.modules.bridge.BridgeModule
|
||||
import java.io.*
|
||||
|
||||
/**
|
||||
* A module for caching versioned modules finds and blacklist, and assets mappings.
|
||||
*
|
||||
* See [ModulesCache] and [AssetsCache] for the cache structure.
|
||||
*
|
||||
* ## Methods
|
||||
*
|
||||
* ### Modules cache
|
||||
*
|
||||
* - `kettu.caches.modules.read() : { blacklist: number[], finds: { [filter: string]: { [id: string]: number } | null }, version: number } | null`
|
||||
* - Reads the modules cache. Returns `null` if the cache file does not exist or is invalid.
|
||||
*
|
||||
* - `kettu.caches.modules.write(blacklist: number[], finds: { [filter: string]: { [id: string]: number } | null }) : void`
|
||||
* - Writes the modules cache.
|
||||
*
|
||||
* ### Assets cache
|
||||
*
|
||||
* - `kettu.caches.assets.read() : { data: { [name: string]: { [type: string]: number } }, version: number } | null`
|
||||
* - Reads the assets cache. Returns `null` if the cache file does not exist or is invalid.
|
||||
*
|
||||
* - `kettu.caches.assets.write(data: { [name: string]: { [type: string]: number } }) : void`
|
||||
* - Writes the assets cache.
|
||||
*
|
||||
* The caches are versioned and tied to the app version code. When the app is updated,
|
||||
* the caches are invalidated and new cache files are created.
|
||||
*/
|
||||
class CacheModule : Module() {
|
||||
private companion object {
|
||||
const val CACHE_DIR = "kettu"
|
||||
|
||||
const val MODULES_CACHE_PREFIX = "modules"
|
||||
const val ASSETS_CACHE_PREFIX = "assets"
|
||||
}
|
||||
|
||||
private lateinit var modulesCache: ModulesCache
|
||||
private lateinit var assetsCache: AssetsCache
|
||||
|
||||
override fun onContext(context: Context) = with(context) {
|
||||
val kettuCacheDir = File(cacheDir, CACHE_DIR).apply { asDir() }
|
||||
val (_, _, _, versionCode) = getAppInfo()
|
||||
|
||||
val modulesCacheFile = File(
|
||||
kettuCacheDir, "$MODULES_CACHE_PREFIX.$versionCode"
|
||||
).apply { asFile() }
|
||||
|
||||
val assetsCacheFile = File(
|
||||
kettuCacheDir, "$ASSETS_CACHE_PREFIX.$versionCode"
|
||||
).apply { asFile() }
|
||||
|
||||
BridgeModule.registerMethod("kettu.caches.modules.read") {
|
||||
if (::modulesCache.isInitialized) modulesCache.toMap() else ModulesCache.loadFromFileOrNull(modulesCacheFile)
|
||||
?.let {
|
||||
modulesCache = it
|
||||
it.toMap()
|
||||
}
|
||||
}
|
||||
|
||||
BridgeModule.registerMethod("kettu.caches.modules.write") {
|
||||
val (blacklist, finds) = it
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
modulesCache = ModulesCache(
|
||||
blacklist as ArrayList<Double>, finds as HashMap<String, HashMap<String, Double>?>
|
||||
).apply { saveToFile(modulesCacheFile) }
|
||||
Log.i("Modules cache saved: ${modulesCacheFile.absolutePath} (blacklisted: ${blacklist.size}, finds: ${finds.size})")
|
||||
}
|
||||
|
||||
BridgeModule.registerMethod("kettu.caches.assets.read") {
|
||||
if (::assetsCache.isInitialized) assetsCache.toMap() else AssetsCache.loadFromFileOrNull(assetsCacheFile)
|
||||
?.let {
|
||||
assetsCache = it
|
||||
it.toMap()
|
||||
}
|
||||
}
|
||||
|
||||
BridgeModule.registerMethod("kettu.caches.assets.write") {
|
||||
val (data) = it
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
assetsCache =
|
||||
AssetsCache(data as HashMap<String, HashMap<String, Double>>).apply { saveToFile(assetsCacheFile) }
|
||||
Log.i("Assets cache saved: ${assetsCacheFile.absolutePath} (count: ${data.size})")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class CacheVersionMismatchException(expected: Int, actual: Int) :
|
||||
Throwable("Expected cache version: $expected, but got version: $actual")
|
||||
|
||||
data class ModulesCache(
|
||||
val blacklist: List<Double>, val finds: Map<String, Map<String, Double>?>
|
||||
) {
|
||||
fun saveToFile(file: File) {
|
||||
val atomic = AtomicFile(file)
|
||||
var fos: FileOutputStream? = null
|
||||
try {
|
||||
fos = atomic.startWrite()
|
||||
val out = DataOutputStream(BufferedOutputStream(fos))
|
||||
|
||||
out.writeInt(VERSION)
|
||||
|
||||
// Write blacklist
|
||||
out.writeInt(blacklist.size)
|
||||
for (num in blacklist) out.writeInt(num.toInt())
|
||||
|
||||
// Write finds
|
||||
out.writeInt(finds.size)
|
||||
for ((filter, matches) in finds) {
|
||||
out.writeUTF(filter)
|
||||
if (matches == null) {
|
||||
out.writeBoolean(false) // null marker
|
||||
} else {
|
||||
out.writeBoolean(true) // not null
|
||||
out.writeInt(matches.size)
|
||||
for ((id, flag) in matches) {
|
||||
out.writeInt(id.toInt())
|
||||
out.writeInt(flag.toInt())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out.flush()
|
||||
atomic.finishWrite(fos)
|
||||
} catch (t: Throwable) {
|
||||
if (fos != null) atomic.failWrite(fos)
|
||||
throw t
|
||||
}
|
||||
}
|
||||
|
||||
fun toMap(): Map<String, Any> {
|
||||
val findsMap = finds.mapValues { (_, matches) -> matches?.map { (k, v) -> k to v }?.toMap() }
|
||||
|
||||
return mapOf("blacklist" to blacklist, "finds" to findsMap, "version" to VERSION)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val VERSION = 3
|
||||
|
||||
fun loadFromFileOrNull(file: File): ModulesCache? {
|
||||
if (!file.exists() || file.length() <= 0L) return null
|
||||
val atomic = AtomicFile(file)
|
||||
try {
|
||||
DataInputStream(BufferedInputStream(atomic.openRead())).use { input ->
|
||||
val version = input.readInt()
|
||||
if (version != VERSION) throw CacheVersionMismatchException(VERSION, version)
|
||||
|
||||
val blacklistSize = input.readInt()
|
||||
val blacklist = MutableList(blacklistSize) { input.readInt().toDouble() }
|
||||
|
||||
val findsSize = input.readInt()
|
||||
val finds = mutableMapOf<String, Map<String, Double>?>()
|
||||
repeat(findsSize) {
|
||||
val filter = input.readUTF()
|
||||
val hasMatches = input.readBoolean()
|
||||
if (!hasMatches) {
|
||||
finds[filter] = null
|
||||
} else {
|
||||
val mapSize = input.readInt()
|
||||
val matches = mutableMapOf<String, Double>()
|
||||
repeat(mapSize) {
|
||||
val id = input.readInt().toString()
|
||||
val flag = input.readInt().toDouble()
|
||||
matches[id] = flag
|
||||
}
|
||||
finds[filter] = matches
|
||||
}
|
||||
}
|
||||
|
||||
return ModulesCache(blacklist, finds)
|
||||
}
|
||||
} catch (e: CacheVersionMismatchException) {
|
||||
Log.i("Modules cache version mismatch: ${e.message}")
|
||||
} catch (e: EOFException) {
|
||||
Log.e("Modules cache corrupt: ${e.message}")
|
||||
} catch (e: IOException) {
|
||||
Log.e("Failed to read modules cache: ${e.message}")
|
||||
}
|
||||
runCatching { file.delete() }
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class AssetsCache(
|
||||
val data: Map<String, Map<String, Double>>
|
||||
) {
|
||||
fun saveToFile(file: File) {
|
||||
val atomic = AtomicFile(file)
|
||||
var fos: FileOutputStream? = null
|
||||
try {
|
||||
fos = atomic.startWrite()
|
||||
val out = DataOutputStream(BufferedOutputStream(fos))
|
||||
|
||||
out.writeInt(VERSION)
|
||||
|
||||
out.writeInt(data.size)
|
||||
for ((name, mappings) in data) {
|
||||
out.writeUTF(name)
|
||||
out.writeInt(mappings.size)
|
||||
for ((type, id) in mappings) {
|
||||
out.writeUTF(type)
|
||||
out.writeInt(id.toInt())
|
||||
}
|
||||
}
|
||||
|
||||
out.flush()
|
||||
atomic.finishWrite(fos)
|
||||
} catch (t: Throwable) {
|
||||
if (fos != null) atomic.failWrite(fos)
|
||||
throw t
|
||||
}
|
||||
}
|
||||
|
||||
fun toMap(): Map<String, Any> {
|
||||
return mapOf(
|
||||
"data" to data.mapValues { (_, mappings) ->
|
||||
mappings.map { (type, id) -> type to id }.toMap()
|
||||
}, "version" to VERSION
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val VERSION = 2
|
||||
|
||||
fun loadFromFileOrNull(file: File): AssetsCache? {
|
||||
if (!file.exists() || file.length() <= 0L) return null
|
||||
val atomic = AtomicFile(file)
|
||||
try {
|
||||
DataInputStream(BufferedInputStream(atomic.openRead())).use { input ->
|
||||
val version = input.readInt()
|
||||
if (version != VERSION) throw CacheVersionMismatchException(VERSION, version)
|
||||
|
||||
val dataSize = input.readInt()
|
||||
val data = mutableMapOf<String, Map<String, Double>>()
|
||||
|
||||
repeat(dataSize) {
|
||||
val name = input.readUTF()
|
||||
val mappingsSize = input.readInt()
|
||||
val mappings = mutableMapOf<String, Double>()
|
||||
|
||||
repeat(mappingsSize) {
|
||||
val type = input.readUTF()
|
||||
val id = input.readInt().toDouble()
|
||||
mappings[type] = id
|
||||
}
|
||||
|
||||
data[name] = mappings
|
||||
}
|
||||
|
||||
return AssetsCache(data)
|
||||
}
|
||||
} catch (e: CacheVersionMismatchException) {
|
||||
Log.i("Assets cache version mismatch: ${e.message}")
|
||||
} catch (e: EOFException) {
|
||||
Log.e("Assets cache corrupt: ${e.message}")
|
||||
} catch (e: IOException) {
|
||||
Log.e("Failed to read assets cache: ${e.message}")
|
||||
}
|
||||
runCatching { file.delete() }
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,9 +6,12 @@ import de.robv.android.xposed.IXposedHookZygoteInit
|
|||
import de.robv.android.xposed.XposedBridge
|
||||
import de.robv.android.xposed.callbacks.XC_LoadPackage
|
||||
import cocobo1.pupu.xposed.Constants
|
||||
import cocobo1.pupu.xposed.HookStateHolder
|
||||
import cocobo1.pupu.xposed.Module
|
||||
import cocobo1.pupu.xposed.Utils.Log
|
||||
import cocobo1.pupu.xposed.modules.HookScriptLoaderModule.Companion.PRELOADS_DIR
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.io.File
|
||||
import java.lang.reflect.Method
|
||||
|
|
@ -19,7 +22,7 @@ import java.lang.reflect.Method
|
|||
* Preload scripts should be placed in the [PRELOADS_DIR] directory inside the module's files directory.
|
||||
*
|
||||
* The main bundle should be placed in the [Constants.CACHE_DIR] directory named [Constants.MAIN_SCRIPT_FILE].
|
||||
* If the bundle file does not exist, it will attempt to load `assets://revenge.bundle` from the module's assets.
|
||||
* If the bundle file does not exist, it will attempt to load `assets://kettu.bundle` from the module's assets.
|
||||
*/
|
||||
class HookScriptLoaderModule : Module() {
|
||||
private lateinit var preloadsDir: File
|
||||
|
|
@ -84,8 +87,12 @@ class HookScriptLoaderModule : Module() {
|
|||
private fun HookScope.runCustomScripts(loadScriptFromFile: Method, loadScriptFromAssets: Method) {
|
||||
Log.i("Running custom scripts...")
|
||||
|
||||
// TODO: Is there a better way to do this?
|
||||
runBlocking { UpdaterModule.job?.join() }
|
||||
runBlocking {
|
||||
val ready = async { HookStateHolder.readyDeferred.join() }
|
||||
val download = async { UpdaterModule.downloadScript().join() }
|
||||
|
||||
awaitAll(ready, download)
|
||||
}
|
||||
|
||||
val loadSynchronously = args[2]
|
||||
val runScriptFile = { file: File ->
|
||||
|
|
@ -115,11 +122,11 @@ class HookScriptLoaderModule : Module() {
|
|||
XposedBridge.invokeOriginalMethod(
|
||||
loadScriptFromAssets,
|
||||
thisObject,
|
||||
arrayOf(resources.assets, "assets://bundle.js", loadSynchronously)
|
||||
arrayOf(resources.assets, "assets://kettu.bundle", loadSynchronously)
|
||||
)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.e("Unable to run scripts:", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
352
app/src/main/kotlin/cocobo1/pupu/xposed/modules/LogBoxModule.kt
Normal file
352
app/src/main/kotlin/cocobo1/pupu/xposed/modules/LogBoxModule.kt
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
package cocobo1.pupu.xposed.modules
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.widget.EditText
|
||||
import android.widget.Toast
|
||||
import de.robv.android.xposed.callbacks.XC_LoadPackage
|
||||
import cocobo1.pupu.xposed.BuildConfig
|
||||
import cocobo1.pupu.xposed.Module
|
||||
import cocobo1.pupu.xposed.Utils
|
||||
import cocobo1.pupu.xposed.Utils.Log
|
||||
import de.robv.android.xposed.XposedHelpers
|
||||
import kotlinx.coroutines.*
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import cocobo1.pupu.xposed.Utils.Companion.reloadApp
|
||||
|
||||
class LogBoxModule : Module() {
|
||||
lateinit var packageParam: XC_LoadPackage.LoadPackageParam
|
||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
|
||||
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
|
||||
this@LogBoxModule.packageParam = packageParam
|
||||
|
||||
val dcdReactNativeHostClass = classLoader.loadClass("com.discord.bridge.DCDReactNativeHost")
|
||||
val getUseDeveloperSupportMethod = dcdReactNativeHostClass.methods.first { it.name == "getUseDeveloperSupport" }
|
||||
|
||||
getUseDeveloperSupportMethod.hook {
|
||||
before {
|
||||
result = true
|
||||
}
|
||||
}
|
||||
|
||||
listOf(
|
||||
"com.facebook.react.devsupport.BridgeDevSupportManager",
|
||||
"com.facebook.react.devsupport.BridgelessDevSupportManager"
|
||||
).mapNotNull { packageParam.classLoader.safeLoadClass(it) }.forEach {
|
||||
hookDevSupportManager(it)
|
||||
}
|
||||
|
||||
return@with
|
||||
}
|
||||
|
||||
private fun hookDevSupportManager(clazz: Class<*>) {
|
||||
val showDevOptionsDialogMethod = clazz.methods.first { it.name == "showDevOptionsDialog" }
|
||||
|
||||
showDevOptionsDialogMethod.hook {
|
||||
before {
|
||||
try {
|
||||
val context = getContextFromDevSupport(clazz, thisObject)
|
||||
if (context != null) {
|
||||
showRecoveryMenu(context)
|
||||
}
|
||||
result = null
|
||||
} catch (e: Exception) {
|
||||
Log.e("Failed to show recovery menu: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getContextFromDevSupport(clazz: Class<*>, instance: Any?): Context? {
|
||||
return try {
|
||||
val mReactInstanceDevHelperField = XposedHelpers.findField(clazz, "mReactInstanceDevHelper")
|
||||
val helper = mReactInstanceDevHelperField.get(instance)
|
||||
val getCurrentActivityMethod = helper?.javaClass?.methods?.first { it.name == "getCurrentActivity" }
|
||||
getCurrentActivityMethod?.invoke(helper) as? Context
|
||||
} catch (e: Exception) {
|
||||
Log.e("Failed to get context: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun reloadReactNative(context: Context) {
|
||||
try {
|
||||
val reactInstanceManagerClass = packageParam.classLoader.loadClass("com.facebook.react.ReactInstanceManager")
|
||||
val reactApplicationClass = packageParam.classLoader.loadClass("com.facebook.react.ReactApplication")
|
||||
|
||||
val app = context.applicationContext
|
||||
|
||||
if (reactApplicationClass.isInstance(app)) {
|
||||
val getReactNativeHostMethod = reactApplicationClass.methods.first { it.name == "getReactNativeHost" }
|
||||
val reactNativeHost = getReactNativeHostMethod.invoke(app)
|
||||
|
||||
val getReactInstanceManagerMethod = reactNativeHost.javaClass.methods.first { it.name == "getReactInstanceManager" }
|
||||
val reactInstanceManager = getReactInstanceManagerMethod.invoke(reactNativeHost)
|
||||
|
||||
if (reactInstanceManager != null) {
|
||||
val reloadMethod = reactInstanceManagerClass.methods.first { it.name == "recreateReactContextInBackground" }
|
||||
reloadMethod.invoke(reactInstanceManager)
|
||||
} else {
|
||||
showError(context, "Error", "Could not get React Instance Manager")
|
||||
}
|
||||
} else {
|
||||
showError(context, "Error", "Application is not a React Application")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("Failed to Reload App: ${e.message}")
|
||||
showError(context, "Failed to reload", "Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun showRecoveryMenu(context: Context) {
|
||||
val options = arrayOf(
|
||||
if (isSafeModeEnabled(context)) "Disable Safe Mode" else "Enable Safe Mode",
|
||||
"Refetch Bundle",
|
||||
"Load Custom Bundle",
|
||||
"Reload App"
|
||||
)
|
||||
|
||||
AlertDialog.Builder(context)
|
||||
.setTitle("KettuXposed Recovery Menu")
|
||||
.setItems(options) { _, which ->
|
||||
handleMenuSelection(context, which)
|
||||
}
|
||||
.setNegativeButton("Cancel", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun handleMenuSelection(context: Context, index: Int) {
|
||||
when (index) {
|
||||
0 -> toggleSafeMode(context)
|
||||
1 -> confirmAction(context, "refetch the bundle") { refetchBundle(context) }
|
||||
2 -> loadCustomBundle(context)
|
||||
3 -> reloadApp()
|
||||
}
|
||||
}
|
||||
|
||||
private fun confirmAction(context: Context, actionText: String, action: () -> Unit) {
|
||||
AlertDialog.Builder(context)
|
||||
.setTitle("Confirm Action")
|
||||
.setMessage("Are you sure you want to $actionText?")
|
||||
.setPositiveButton("Confirm") { _, _ -> action() }
|
||||
.setNegativeButton("Cancel", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun isSafeModeEnabled(context: Context): Boolean {
|
||||
return try {
|
||||
val settingsFile = File(context.filesDir, "vd_mmkv/VENDETTA_SETTINGS")
|
||||
if (!settingsFile.exists()) return false
|
||||
|
||||
val json = JSONObject(settingsFile.readText())
|
||||
json.optJSONObject("safeMode")?.optBoolean("enabled", false) ?: false
|
||||
} catch (e: Exception) {
|
||||
Log.e("Error checking safe mode: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun toggleSafeMode(context: Context) {
|
||||
try {
|
||||
val settingsFile = File(context.filesDir, "vd_mmkv/VENDETTA_SETTINGS")
|
||||
val themeFile = File(context.filesDir, "vd_mmkv/VENDETTA_THEMES")
|
||||
|
||||
settingsFile.parentFile?.mkdirs()
|
||||
|
||||
val settings = if (settingsFile.exists()) {
|
||||
JSONObject(settingsFile.readText())
|
||||
} else {
|
||||
JSONObject()
|
||||
}
|
||||
|
||||
val safeMode = settings.optJSONObject("safeMode") ?: JSONObject()
|
||||
val currentState = safeMode.optBoolean("enabled", false)
|
||||
val newState = !currentState
|
||||
|
||||
safeMode.put("enabled", newState)
|
||||
|
||||
if (newState && themeFile.exists()) {
|
||||
val theme = JSONObject(themeFile.readText())
|
||||
val themeId = theme.optString("id")
|
||||
if (themeId.isNotEmpty()) {
|
||||
safeMode.put("currentThemeId", themeId)
|
||||
themeFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
settings.put("safeMode", safeMode)
|
||||
settingsFile.writeText(settings.toString())
|
||||
|
||||
Toast.makeText(context, "Safe Mode ${if (newState) "Enabled" else "Disabled"}", Toast.LENGTH_SHORT).show()
|
||||
|
||||
reloadApp()
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("Error toggling safe mode: ${e.message}")
|
||||
showError(context, "Failed to toggle safe mode", e.message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun refetchBundle(context: Context) {
|
||||
try {
|
||||
val pyoncordDir = getPyoncordDirectory(context)
|
||||
val bundleFile = File(pyoncordDir, "bundle.js")
|
||||
val backupFile = File(pyoncordDir, "bundle.js.backup")
|
||||
|
||||
if (bundleFile.exists()) {
|
||||
backupFile.delete()
|
||||
bundleFile.renameTo(backupFile)
|
||||
Log.e("Bundle moved to backup")
|
||||
}
|
||||
|
||||
reloadApp()
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("Error refetching bundle: ${e.message}")
|
||||
showError(context, "Failed to refetch bundle", e.message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetBundle(context: Context) {
|
||||
try {
|
||||
val pyoncordDir = getPyoncordDirectory(context)
|
||||
val bundleFile = File(pyoncordDir, "bundle.js")
|
||||
val configFile = File(pyoncordDir, "loader_config.json")
|
||||
|
||||
bundleFile.delete()
|
||||
|
||||
// Reset config
|
||||
if (configFile.exists()) {
|
||||
val config = JSONObject(configFile.readText())
|
||||
config.put("customLoadUrlEnabled", false)
|
||||
config.put("customLoadUrl", "http://localhost:4040/kettu.js")
|
||||
configFile.writeText(config.toString())
|
||||
}
|
||||
|
||||
reloadApp()
|
||||
} catch (e: Exception) {
|
||||
Log.e("Error resetting bundle: ${e.message}")
|
||||
showError(context, "Failed to reset bundle", e.message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadCustomBundle(context: Context) {
|
||||
val input = EditText(context).apply {
|
||||
hint = "http://localhost:4040/kettu.js"
|
||||
inputType = android.text.InputType.TYPE_TEXT_VARIATION_URI
|
||||
}
|
||||
|
||||
AlertDialog.Builder(context)
|
||||
.setTitle("Load Custom Bundle")
|
||||
.setMessage("Enter the URL for your custom bundle:")
|
||||
.setView(input)
|
||||
.setPositiveButton("Load") { _, _ ->
|
||||
val urlString = input.text.toString().trim()
|
||||
if (urlString.isEmpty()) {
|
||||
showError(context, "Invalid URL", "Please enter a URL")
|
||||
return@setPositiveButton
|
||||
}
|
||||
|
||||
validateAndLoadBundle(context, urlString)
|
||||
}
|
||||
.setNegativeButton("Cancel", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun validateAndLoadBundle(context: Context, urlString: String) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val url = URL(urlString)
|
||||
if (url.protocol != "http" && url.protocol != "https") {
|
||||
withContext(Dispatchers.Main) {
|
||||
showError(context, "Invalid URL", "URL must start with http:// or https://")
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
val connection = url.openConnection() as HttpURLConnection
|
||||
connection.requestMethod = "GET"
|
||||
connection.connectTimeout = 10000
|
||||
connection.readTimeout = 10000
|
||||
|
||||
val responseCode = connection.responseCode
|
||||
if (responseCode != 200) {
|
||||
withContext(Dispatchers.Main) {
|
||||
showError(context, "Error", "Server returned error $responseCode")
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
val contentType = connection.contentType
|
||||
if (contentType != null && !contentType.contains("javascript") && !contentType.contains("text")) {
|
||||
withContext(Dispatchers.Main) {
|
||||
showError(context, "Invalid Content", "URL must point to a JavaScript file")
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Save custom bundle URL
|
||||
withContext(Dispatchers.Main) {
|
||||
setCustomBundleURL(context, urlString)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
showError(context, "Connection Error", "Could not reach URL: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setCustomBundleURL(context: Context, url: String) {
|
||||
try {
|
||||
val pyoncordDir = getPyoncordDirectory(context)
|
||||
val configFile = File(pyoncordDir, "loader_config.json")
|
||||
|
||||
val config = if (configFile.exists()) {
|
||||
JSONObject(configFile.readText())
|
||||
} else {
|
||||
JSONObject()
|
||||
}
|
||||
|
||||
config.put("customLoadUrlEnabled", true)
|
||||
config.put("customLoadUrl", url)
|
||||
configFile.writeText(config.toString())
|
||||
|
||||
File(pyoncordDir, "bundle.js").delete()
|
||||
|
||||
Toast.makeText(context, "Custom bundle set", Toast.LENGTH_SHORT).show()
|
||||
|
||||
reloadApp()
|
||||
} catch (e: Exception) {
|
||||
Log.e("Error setting custom bundle URL: ${e.message}")
|
||||
showError(context, "Failed to save configuration", e.message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getPyoncordDirectory(context: Context): File {
|
||||
val dir = File(context.filesDir, "pyoncord")
|
||||
if (!dir.exists()) {
|
||||
dir.mkdirs()
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
private fun showError(context: Context, title: String, message: String?) {
|
||||
AlertDialog.Builder(context)
|
||||
.setTitle(title)
|
||||
.setMessage(message ?: "An unknown error occurred")
|
||||
.setPositiveButton("OK", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
override fun onContext(context: Context) {
|
||||
}
|
||||
}
|
||||
124
app/src/main/kotlin/cocobo1/pupu/xposed/modules/PluginsModule.kt
Normal file
124
app/src/main/kotlin/cocobo1/pupu/xposed/modules/PluginsModule.kt
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
package cocobo1.pupu.xposed.modules
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AtomicFile
|
||||
import cocobo1.pupu.xposed.Module
|
||||
import cocobo1.pupu.xposed.Utils.Log
|
||||
import cocobo1.pupu.xposed.modules.bridge.BridgeModule
|
||||
import java.io.*
|
||||
|
||||
/**
|
||||
* Manages the states of plugins, allowing reading and writing of plugin states to a file.
|
||||
*
|
||||
* ## Methods
|
||||
*
|
||||
* - `kettu.plugins.states.read(): { flags: { [pluginId: string]: number } }`
|
||||
* - Reads the current plugin states from the file and returns them as a map.
|
||||
*
|
||||
* - `kettu.plugins.states.write(flags: { [pluginId: string]: number }): void`
|
||||
* - Writes the provided plugin states to the file.
|
||||
*/
|
||||
class PluginsModule : Module() {
|
||||
private companion object {
|
||||
const val DATA_DIR = "kettu/plugins"
|
||||
const val STATES_FILE = "states"
|
||||
}
|
||||
|
||||
private lateinit var states: PluginStates
|
||||
|
||||
override fun onContext(context: Context) = with(context) {
|
||||
val dataDir = File(filesDir, DATA_DIR)
|
||||
|
||||
val statesFile = File(
|
||||
dataDir, STATES_FILE
|
||||
).apply { asFile() }
|
||||
|
||||
BridgeModule.registerMethod("kettu.plugins.states.read") {
|
||||
if (::states.isInitialized) states.toMap() else
|
||||
PluginStates.loadFromFileOrNull(statesFile)?.let {
|
||||
states = it
|
||||
it.toMap()
|
||||
}
|
||||
}
|
||||
|
||||
BridgeModule.registerMethod("kettu.plugins.states.write") {
|
||||
val (flags) = it
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
states = PluginStates(
|
||||
flags as Map<String, Double>
|
||||
).apply { saveToFile(statesFile) }
|
||||
Log.i("Plugin states saved: ${statesFile.absolutePath}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class UnsupportedPluginStatesVersionException(version: Int) :
|
||||
Throwable("Unsupported plugin states version: $version")
|
||||
|
||||
data class PluginStates(
|
||||
val flags: Map<String, Double>
|
||||
) {
|
||||
fun saveToFile(file: File) {
|
||||
val atomic = AtomicFile(file)
|
||||
var fos: FileOutputStream? = null
|
||||
try {
|
||||
fos = atomic.startWrite()
|
||||
val out = DataOutputStream(BufferedOutputStream(fos))
|
||||
|
||||
out.writeInt(CURRENT_VERSION)
|
||||
|
||||
// Write flags
|
||||
out.writeInt(flags.size)
|
||||
for ((id, flags) in flags) {
|
||||
out.writeUTF(id)
|
||||
out.writeInt(flags.toInt())
|
||||
}
|
||||
|
||||
out.flush()
|
||||
atomic.finishWrite(fos)
|
||||
} catch (t: Throwable) {
|
||||
if (fos != null) atomic.failWrite(fos)
|
||||
throw t
|
||||
}
|
||||
}
|
||||
|
||||
fun toMap(): Map<String, Any> = mapOf("flags" to flags)
|
||||
|
||||
companion object {
|
||||
const val CURRENT_VERSION = 1
|
||||
|
||||
fun loadFromFileOrNull(file: File): PluginStates? {
|
||||
if (!file.exists() || file.length() <= 0L) return null
|
||||
val atomic = AtomicFile(file)
|
||||
|
||||
try {
|
||||
DataInputStream(BufferedInputStream(atomic.openRead())).use { input ->
|
||||
when (val version = input.readInt()) {
|
||||
1 -> return loadV1FromFileOrNull(input)
|
||||
else -> throw UnsupportedPluginStatesVersionException(version)
|
||||
}
|
||||
}
|
||||
} catch (e: UnsupportedPluginStatesVersionException) {
|
||||
Log.i(e.message!!)
|
||||
} catch (e: EOFException) {
|
||||
Log.e("Plugin states corrupt: ${e.message}")
|
||||
} catch (e: IOException) {
|
||||
Log.e("Failed to read plugin states: ${e.message}")
|
||||
}
|
||||
|
||||
runCatching { file.delete() }
|
||||
return null
|
||||
}
|
||||
|
||||
private fun loadV1FromFileOrNull(input: DataInputStream): PluginStates {
|
||||
val flagsSize = input.readInt()
|
||||
val flags = mutableMapOf<String, Double>()
|
||||
|
||||
repeat(flagsSize) {
|
||||
flags[input.readUTF()] = input.readInt().toDouble()
|
||||
}
|
||||
|
||||
return PluginStates(flags)
|
||||
}
|
||||
}
|
||||
}
|
||||
152
app/src/main/kotlin/cocobo1/pupu/xposed/modules/UpdaterModule.kt
Normal file
152
app/src/main/kotlin/cocobo1/pupu/xposed/modules/UpdaterModule.kt
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
package cocobo1.pupu.xposed.modules
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.AlertDialog
|
||||
import android.content.Context
|
||||
import android.util.AtomicFile
|
||||
import android.widget.Toast
|
||||
import androidx.core.util.writeBytes
|
||||
import de.robv.android.xposed.callbacks.XC_LoadPackage
|
||||
import cocobo1.pupu.xposed.Constants
|
||||
import cocobo1.pupu.xposed.Module
|
||||
import cocobo1.pupu.xposed.Utils
|
||||
import cocobo1.pupu.xposed.Utils.Companion.JSON
|
||||
import cocobo1.pupu.xposed.Utils.Companion.reloadApp
|
||||
import cocobo1.pupu.xposed.Utils.Log
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.engine.cio.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.io.File
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
@Serializable
|
||||
data class CustomLoadUrl(
|
||||
val enabled: Boolean = false, val url: String = ""
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LoaderConfig(
|
||||
val customLoadUrl: CustomLoadUrl = CustomLoadUrl()
|
||||
)
|
||||
|
||||
/**
|
||||
* Module that updates the JS bundle by downloading it from a remote URL.
|
||||
*
|
||||
* Shows dialogs when failed allowing retry.
|
||||
*/
|
||||
object UpdaterModule : Module() {
|
||||
private lateinit var config: LoaderConfig
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var lastActivity: WeakReference<Activity>? = null
|
||||
|
||||
private lateinit var cacheDir: File
|
||||
private lateinit var bundle: File
|
||||
private lateinit var etag: File
|
||||
|
||||
private const val TIMEOUT_CACHED = 5000L
|
||||
private const val TIMEOUT = 10000L
|
||||
private const val ETAG_FILE = "etag.txt"
|
||||
private const val CONFIG_FILE = "loader.json"
|
||||
|
||||
private const val DEFAULT_BUNDLE_URL =
|
||||
"https://codeberg.org/cocobo1/Kettu/raw/branch/dist/kettu.min.js"
|
||||
|
||||
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
|
||||
cacheDir = File(appInfo.dataDir, Constants.CACHE_DIR).apply { mkdirs() }
|
||||
val filesDir = File(appInfo.dataDir, Constants.FILES_DIR).apply { mkdirs() }
|
||||
|
||||
bundle = File(cacheDir, Constants.MAIN_SCRIPT_FILE)
|
||||
etag = File(cacheDir, ETAG_FILE)
|
||||
|
||||
val configFile = File(filesDir, CONFIG_FILE)
|
||||
|
||||
config = runCatching {
|
||||
if (configFile.exists()) {
|
||||
JSON.decodeFromString<LoaderConfig>(configFile.readText())
|
||||
} else LoaderConfig()
|
||||
}.getOrDefault(LoaderConfig())
|
||||
}
|
||||
|
||||
fun downloadScript(activity: Activity? = null): Job = scope.launch {
|
||||
try {
|
||||
HttpClient(CIO) {
|
||||
expectSuccess = false
|
||||
install(UserAgent) { agent = Constants.USER_AGENT }
|
||||
install(HttpRedirect) {}
|
||||
}.use { client ->
|
||||
val url = config.customLoadUrl.takeIf { it.enabled }?.url ?: DEFAULT_BUNDLE_URL
|
||||
Log.i("Fetching JS bundle from: $url")
|
||||
|
||||
val response: HttpResponse = client.get(url) {
|
||||
headers {
|
||||
if (etag.exists() && bundle.exists()) {
|
||||
append(HttpHeaders.IfNoneMatch, etag.readText())
|
||||
}
|
||||
}
|
||||
|
||||
// Retries don't need timeout
|
||||
if (activity == null) {
|
||||
timeout {
|
||||
requestTimeoutMillis = if (!bundle.exists()) TIMEOUT else TIMEOUT_CACHED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (response.status) {
|
||||
HttpStatusCode.OK -> {
|
||||
val bytes: ByteArray = response.body()
|
||||
AtomicFile(bundle).writeBytes(bytes)
|
||||
|
||||
val newTag = response.headers[HttpHeaders.ETag]
|
||||
if (!newTag.isNullOrEmpty()) etag.writeText(newTag) else etag.delete()
|
||||
|
||||
Log.i("Bundle updated (${bytes.size} bytes)")
|
||||
|
||||
// This is a retry, so we show a dialog
|
||||
if (activity != null) {
|
||||
withContext(Dispatchers.Main) {
|
||||
AlertDialog.Builder(activity).setTitle("Kettu Update Successful")
|
||||
.setMessage("A reload is required for changes to take effect.")
|
||||
.setPositiveButton("Reload") { dialog, _ ->
|
||||
reloadApp()
|
||||
dialog.dismiss()
|
||||
}.setCancelable(false).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HttpStatusCode.NotModified -> {
|
||||
Log.i("Server responded with 304, no changes")
|
||||
}
|
||||
|
||||
else -> {
|
||||
throw ResponseException(response, "Received status: ${response.status}")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.e("Failed to download script", e)
|
||||
showErrorDialog(e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivity(activity: Activity) {
|
||||
lastActivity = WeakReference(activity)
|
||||
}
|
||||
|
||||
fun showErrorDialog(e: Throwable) {
|
||||
val activity = lastActivity?.get() ?: return
|
||||
}
|
||||
|
||||
fun resetLoaderConfig(context: Context) {
|
||||
val filesDir = File(context.dataDir, Constants.FILES_DIR)
|
||||
val config = File(filesDir, CONFIG_FILE)
|
||||
if (config.exists()) config.delete()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
package cocobo1.pupu.xposed.modules.bridge
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.AlertDialog
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import cocobo1.pupu.xposed.Module
|
||||
import cocobo1.pupu.xposed.Utils
|
||||
import java.io.File
|
||||
|
||||
class AdditionalBridgeMethodsModule : Module() {
|
||||
override fun onContext(context: Context) = with(context) {
|
||||
BridgeModule.registerMethod("kettu.fs.getConstants") {
|
||||
mapOf(
|
||||
"data" to dataDir.absolutePath,
|
||||
"files" to filesDir.absolutePath,
|
||||
"cache" to cacheDir.absolutePath,
|
||||
)
|
||||
}
|
||||
|
||||
BridgeModule.registerMethod("kettu.fs.delete") {
|
||||
val (path) = it
|
||||
File(path as String).run {
|
||||
if (this.isDirectory) this.deleteRecursively()
|
||||
else this.delete()
|
||||
}
|
||||
}
|
||||
|
||||
BridgeModule.registerMethod("kettu.fs.exists") {
|
||||
val (path) = it
|
||||
File(path as String).exists()
|
||||
}
|
||||
|
||||
BridgeModule.registerMethod("kettu.fs.read") { it ->
|
||||
val (path) = it
|
||||
val file = File(path as String).apply { openFileGuarded() }
|
||||
|
||||
file.bufferedReader().use { it.readText() }
|
||||
}
|
||||
|
||||
BridgeModule.registerMethod("kettu.fs.write") {
|
||||
val (path, contents) = it
|
||||
val file = File(path as String).apply { openFileGuarded() }
|
||||
|
||||
file.writeText(contents as String)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivity(activity: Activity) = with(activity) {
|
||||
BridgeModule.registerMethod("kettu.alertError") {
|
||||
val (error, version) = it
|
||||
val app = getAppInfo()
|
||||
val errorString = "$error"
|
||||
|
||||
val clipboard = applicationContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText("Stack Trace", errorString)
|
||||
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("Kettu Error")
|
||||
.setMessage(
|
||||
"""
|
||||
Kettu: $version
|
||||
${app.name}: ${app.version} (${app.versionCode})
|
||||
Device: ${Build.MANUFACTURER} ${Build.MODEL}
|
||||
|
||||
|
||||
""".trimIndent() + errorString
|
||||
)
|
||||
.setPositiveButton("OK") { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
}
|
||||
.setNeutralButton("Copy") { dialog, _ ->
|
||||
clipboard.setPrimaryClip(clip)
|
||||
Toast.makeText(applicationContext, "Copied stack trace", Toast.LENGTH_SHORT).show()
|
||||
dialog.dismiss()
|
||||
}
|
||||
.show()
|
||||
|
||||
null
|
||||
}
|
||||
|
||||
BridgeModule.registerMethod("kettu.showRecoveryAlert") {
|
||||
Utils.showRecoveryAlert(this)
|
||||
}
|
||||
}
|
||||
|
||||
private fun File.openFileGuarded() {
|
||||
if (!this.exists()) throw Error("Path does not exist: $path")
|
||||
if (!this.isFile) throw Error("Path is not a file: $path")
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,6 @@ import cocobo1.pupu.xposed.BuildConfig
|
|||
import cocobo1.pupu.xposed.Constants
|
||||
import cocobo1.pupu.xposed.Module
|
||||
import cocobo1.pupu.xposed.Utils.Log
|
||||
import java.io.File
|
||||
import java.lang.reflect.Method
|
||||
|
||||
/**
|
||||
|
|
@ -24,7 +23,7 @@ typealias BridgeMethodArgs = ArrayList<Any>
|
|||
* To call a method, pass an object with the following structure to a hooked method:
|
||||
* ```js
|
||||
* {
|
||||
* revenge: {
|
||||
* kettu: {
|
||||
* method: "method.name",
|
||||
* args: [arg1, arg2, ...]
|
||||
* }
|
||||
|
|
@ -52,20 +51,15 @@ class BridgeModule : Module() {
|
|||
private lateinit var argumentsMakeNative: Method
|
||||
|
||||
companion object {
|
||||
private const val CALL_DATA_KEY = "revenge"
|
||||
private const val CALL_DATA_KEY = "kettu"
|
||||
private const val METHOD_NAME_KEY = "method"
|
||||
private const val METHOD_ARGS_KEY = "args"
|
||||
|
||||
private fun File.openFileGuarded() {
|
||||
if (!this.exists()) throw Error("Path does not exist: $path")
|
||||
if (!this.isFile) throw Error("Path is not a file: $path")
|
||||
}
|
||||
|
||||
private val methods: MutableMap<String, BridgeMethodCallback> = mutableMapOf("revenge.info" to {
|
||||
private val methods: MutableMap<String, BridgeMethodCallback> = mutableMapOf("kettu.info" to {
|
||||
mapOf(
|
||||
"name" to Constants.LOADER_NAME, "version" to BuildConfig.VERSION_CODE
|
||||
)
|
||||
}, "revenge.test" to {
|
||||
}, "kettu.test" to {
|
||||
mapOf(
|
||||
"string" to "string",
|
||||
"number" to 7256,
|
||||
|
|
@ -74,25 +68,6 @@ class BridgeModule : Module() {
|
|||
"boolean" to false,
|
||||
"args" to it,
|
||||
)
|
||||
}, "revenge.fs.delete" to {
|
||||
val (path) = it
|
||||
File(path as String).run {
|
||||
if (this.isDirectory) this.deleteRecursively()
|
||||
else this.delete()
|
||||
}
|
||||
}, "revenge.fs.exists" to {
|
||||
val (path) = it
|
||||
File(path as String).exists()
|
||||
}, "revenge.fs.read" to { it ->
|
||||
val (path) = it
|
||||
val file = File(path as String).apply { openFileGuarded() }
|
||||
|
||||
file.bufferedReader().use { it.readText() }
|
||||
}, "revenge.fs.write" to { it ->
|
||||
val (path, contents) = it
|
||||
val file = File(path as String).apply { openFileGuarded() }
|
||||
|
||||
file.writeText(contents as String)
|
||||
})
|
||||
|
||||
/**
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
package io.github.revenge.xposed.modules.no_track
|
||||
package cocobo1.pupu.xposed.modules.no_track
|
||||
|
||||
import android.content.Context
|
||||
import de.robv.android.xposed.callbacks.XC_LoadPackage
|
||||
import io.github.revenge.xposed.Module
|
||||
import io.github.revenge.xposed.Utils.Log
|
||||
import cocobo1.pupu.xposed.Module
|
||||
import cocobo1.pupu.xposed.Utils.Log
|
||||
|
||||
/**
|
||||
* Hooks Discord's crash reporting to disable Sentry initialization.
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
package io.github.revenge.xposed.modules.no_track
|
||||
package cocobo1.pupu.xposed.modules.no_track
|
||||
|
||||
import android.content.Context
|
||||
import de.robv.android.xposed.callbacks.XC_LoadPackage
|
||||
import io.github.revenge.xposed.Module
|
||||
import io.github.revenge.xposed.Utils.Log
|
||||
import cocobo1.pupu.xposed.Module
|
||||
import cocobo1.pupu.xposed.Utils.Log
|
||||
|
||||
/**
|
||||
* Hooks Discord's deep links tracking to disable AppsFlyer initialization.
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
package cocobo1.pupu.xposed.modules
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.content.Context
|
||||
import de.robv.android.xposed.XC_MethodHook
|
||||
import de.robv.android.xposed.XposedBridge
|
||||
import de.robv.android.xposed.XposedHelpers
|
||||
import de.robv.android.xposed.callbacks.XC_LoadPackage
|
||||
import cocobo1.pupu.xposed.Constants
|
||||
import cocobo1.pupu.xposed.Module
|
||||
import cocobo1.pupu.xposed.Utils.Companion.reloadApp
|
||||
import cocobo1.pupu.xposed.Utils.Log
|
||||
import java.io.File
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
class LogBoxModule : Module() {
|
||||
lateinit var packageParam: XC_LoadPackage.LoadPackageParam
|
||||
lateinit var bridgeDevSupportManagerClass: Class<*>
|
||||
|
||||
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
|
||||
this@LogBoxModule.packageParam = packageParam
|
||||
|
||||
// Only enable this module in debug builds
|
||||
if (!BuildConfig.DEBUG) return@with
|
||||
|
||||
val dcdReactNativeHostClass = classLoader.loadClass("com.discord.bridge.DCDReactNativeHost")
|
||||
val getUseDeveloperSupportMethod = dcdReactNativeHostClass.methods.first { it.name == "getUseDeveloperSupport" }
|
||||
|
||||
val getUseDeveloperSupportMethod = dcdReactNativeHostClass.methods.first { it.name == "getUseDeveloperSupport" }
|
||||
val handleReloadJSMethod = bridgeDevSupportManagerClass.methods.first { it.name == "handleReloadJS" }
|
||||
val showDevOptionsDialogMethod = bridgeDevSupportManagerClass.methods.first { it.name == "showDevOptionsDialog" }
|
||||
val mReactInstanceDevHelperField = XposedHelpers.findField(bridgeDevSupportManagerClass, "mReactInstanceDevHelper")
|
||||
|
||||
// This enables the LogBox and opens dev option on shake
|
||||
XposedBridge.hookMethod(getUseDeveloperSupportMethod, object : XC_MethodHook() {
|
||||
override fun beforeHookedMethod(param: MethodHookParam) {
|
||||
param.result = true
|
||||
}
|
||||
})
|
||||
|
||||
// Replace the method to direct relaunch the app instead of sending reload command to developer server
|
||||
XposedBridge.hookMethod(handleReloadJSMethod, object : XC_MethodHook() {
|
||||
override fun beforeHookedMethod(param: MethodHookParam) {
|
||||
reloadApp()
|
||||
param.result = null
|
||||
}
|
||||
})
|
||||
|
||||
// Triggered on shake - Moved from onContext to onLoad
|
||||
XposedBridge.hookMethod(showDevOptionsDialogMethod, object : XC_MethodHook() {
|
||||
override fun beforeHookedMethod(param: MethodHookParam) {
|
||||
try {
|
||||
val mReactInstanceDevHelper = mReactInstanceDevHelperField.get(param.thisObject)
|
||||
val getCurrentActivityMethod = mReactInstanceDevHelper.javaClass.methods.first { it.name == "getCurrentActivity" }
|
||||
val context = getCurrentActivityMethod.invoke(mReactInstanceDevHelper) as Context
|
||||
|
||||
showRecoveryAlert(context)
|
||||
} catch (ex: Exception) {
|
||||
Log.e("Failed to show dev options dialog: $ex")
|
||||
}
|
||||
|
||||
// Ignore the original dev menu
|
||||
param.result = null
|
||||
}
|
||||
})
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("Error in LogBoxModule.onLoad: $e")
|
||||
}
|
||||
|
||||
return@with
|
||||
}
|
||||
|
||||
private fun showRecoveryAlert(context: Context) {
|
||||
AlertDialog.Builder(context).setTitle("Kettu Recovery Options")
|
||||
.setItems(arrayOf("Reload", "Delete bundle.js")) { _, which ->
|
||||
when (which) {
|
||||
0 -> {
|
||||
reloadApp()
|
||||
}
|
||||
|
||||
1 -> {
|
||||
val bundleFile =
|
||||
File(packageParam.appInfo.dataDir, "${Constants.CACHE_DIR}/${Constants.MAIN_SCRIPT_FILE}")
|
||||
if (bundleFile.exists()) bundleFile.delete()
|
||||
|
||||
reloadApp()
|
||||
}
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
package cocobo1.pupu.xposed.modules
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.AlertDialog
|
||||
import android.util.AtomicFile
|
||||
import android.widget.Toast
|
||||
import androidx.core.util.writeBytes
|
||||
import de.robv.android.xposed.callbacks.XC_LoadPackage
|
||||
import cocobo1.pupu.xposed.Constants
|
||||
import cocobo1.pupu.xposed.Module
|
||||
import cocobo1.pupu.xposed.Utils.Companion.JSON
|
||||
import cocobo1.pupu.xposed.Utils.Companion.reloadApp
|
||||
import cocobo1.pupu.xposed.Utils.Log
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.engine.cio.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.io.File
|
||||
|
||||
@Serializable
|
||||
data class CustomLoadUrl(
|
||||
val enabled: Boolean = false, val url: String = ""
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LoaderConfig(
|
||||
val customLoadUrl: CustomLoadUrl = CustomLoadUrl()
|
||||
)
|
||||
|
||||
/**
|
||||
* Module that updates the JS bundle by downloading it from a remote URL.
|
||||
*
|
||||
* Shows dialogs when failed allowing retry.
|
||||
*/
|
||||
class UpdaterModule : Module() {
|
||||
private lateinit var config: LoaderConfig
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var error: Throwable? = null
|
||||
|
||||
private lateinit var cacheDir: File
|
||||
private lateinit var bundle: File
|
||||
private lateinit var etag: File
|
||||
|
||||
companion object {
|
||||
var job: Job? = null
|
||||
|
||||
private const val TIMEOUT_CACHED = 5000L
|
||||
private const val TIMEOUT = 50000L
|
||||
private const val ETAG_FILE = "etag.txt"
|
||||
private const val CONFIG_FILE = "loader.json"
|
||||
|
||||
private const val DEFAULT_BUNDLE_URL =
|
||||
"https://codeberg.org/cocobo1/Kettu/raw/branch/dist/kettu.min.js"
|
||||
}
|
||||
|
||||
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
|
||||
cacheDir = File(appInfo.dataDir, Constants.CACHE_DIR).apply { mkdirs() }
|
||||
val filesDir = File(appInfo.dataDir, Constants.FILES_DIR).apply { mkdirs() }
|
||||
|
||||
bundle = File(cacheDir, Constants.MAIN_SCRIPT_FILE)
|
||||
etag = File(cacheDir, ETAG_FILE)
|
||||
|
||||
val configFile = File(filesDir, CONFIG_FILE)
|
||||
|
||||
config = runCatching {
|
||||
if (configFile.exists()) {
|
||||
JSON.decodeFromString<LoaderConfig>(configFile.readText())
|
||||
} else LoaderConfig()
|
||||
}.getOrDefault(LoaderConfig())
|
||||
|
||||
downloadScript()
|
||||
}
|
||||
|
||||
private fun downloadScript(activity: Activity? = null) {
|
||||
job = scope.launch {
|
||||
try {
|
||||
HttpClient(CIO) {
|
||||
expectSuccess = false
|
||||
install(UserAgent) { agent = Constants.USER_AGENT }
|
||||
install(HttpRedirect) {}
|
||||
}.use { client ->
|
||||
val url = config.customLoadUrl.takeIf { it.enabled }?.url ?: DEFAULT_BUNDLE_URL
|
||||
Log.i("Fetching JS bundle from: $url")
|
||||
|
||||
val response: HttpResponse = client.get(url) {
|
||||
headers {
|
||||
if (etag.exists() && bundle.exists()) {
|
||||
append(HttpHeaders.IfNoneMatch, etag.readText())
|
||||
}
|
||||
}
|
||||
|
||||
// Retries don't need timeout
|
||||
if (activity != null) {
|
||||
timeout {
|
||||
requestTimeoutMillis = if (!bundle.exists()) TIMEOUT else TIMEOUT_CACHED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (response.status) {
|
||||
HttpStatusCode.OK -> {
|
||||
val bytes: ByteArray = response.body()
|
||||
AtomicFile(bundle).writeBytes(bytes)
|
||||
|
||||
val newTag = response.headers[HttpHeaders.ETag]
|
||||
if (!newTag.isNullOrEmpty()) etag.writeText(newTag) else etag.delete()
|
||||
|
||||
Log.i("Bundle updated (${bytes.size} bytes)")
|
||||
|
||||
// This is a retry, so we show a dialog
|
||||
if (activity != null) {
|
||||
withContext(Dispatchers.Main) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HttpStatusCode.NotModified -> {
|
||||
Log.i("Server responded with 304, no changes")
|
||||
}
|
||||
|
||||
else -> {
|
||||
throw ResponseException(response, "Received status: ${response.status}")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.e("Failed to download script", e)
|
||||
error = e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivity(activity: Activity) {
|
||||
error ?: return
|
||||
error = null
|
||||
downloadScript(activity)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
package cocobo1.pupu.xposed.modules.bridge
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import cocobo1.pupu.xposed.Module
|
||||
|
||||
class AdditionalBridgeMethodsModule : Module() {
|
||||
override fun onContext(context: Context) = with(context) {
|
||||
BridgeModule.registerMethod("revenge.alertError") {
|
||||
val (error, version) = it
|
||||
val app = getAppInfo()
|
||||
val errorString = "$error"
|
||||
|
||||
val clipboard = applicationContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText("Stack Trace", errorString)
|
||||
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("Kettu Error")
|
||||
.setMessage(
|
||||
"""
|
||||
Kettu: $version
|
||||
${app.name}: ${app.version} (${app.versionCode})
|
||||
Device: ${Build.MANUFACTURER} ${Build.MODEL}
|
||||
|
||||
|
||||
""".trimIndent() + errorString
|
||||
)
|
||||
.setPositiveButton("OK") { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
}
|
||||
.setNeutralButton("Copy") { dialog, _ ->
|
||||
clipboard.setPrimaryClip(clip)
|
||||
Toast.makeText(applicationContext, "Copied stack trace", Toast.LENGTH_SHORT).show()
|
||||
dialog.dismiss()
|
||||
}
|
||||
.show()
|
||||
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue