Compare commits
368 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a707e6fd05 | ||
|
|
d55ed5221c | ||
|
|
a5af0bddb8 | ||
|
|
9611038048 | ||
|
|
06dde39cfd | ||
|
|
69496c1f3c | ||
|
|
ce6f201fcd | ||
|
|
73a93a29bb | ||
|
|
ee7e30e67c | ||
|
|
57ebd3daed | ||
|
|
82214b6ed0 | ||
|
|
2b9956d97b | ||
|
|
b2c7e23a57 | ||
|
|
468311bbba | ||
|
|
7f90a61ca5 | ||
|
|
17e1b9220f | ||
|
|
34b4a9f30c | ||
|
|
abb1c6ddd8 | ||
|
|
32421df002 | ||
|
|
f16af054be | ||
|
|
3a6abc9a79 | ||
|
|
a836fd1e73 | ||
|
|
0190e2d468 | ||
|
|
ea21c20d18 | ||
|
|
ad10c8a2a1 | ||
|
|
65d6ae239a | ||
|
|
415f0066d7 | ||
|
|
4f3c599c3b | ||
|
|
5e8e4538f8 | ||
|
|
64f83dcd5c | ||
|
|
cd418c2d79 | ||
|
|
0267d095e2 | ||
|
|
2d62630e30 | ||
|
|
750a4cd5b0 | ||
|
|
baacefef54 | ||
|
|
f96d53db9a | ||
|
|
720e84487e | ||
|
|
8616f04995 | ||
|
|
7152b06265 | ||
|
|
2b3510f55d | ||
|
|
d1334d0ec0 | ||
|
|
853564ebe9 | ||
|
|
3bf2f76c56 | ||
|
|
6f707af677 | ||
|
|
1abaaff7d1 | ||
|
|
3aa888bc26 | ||
|
|
d57081364b | ||
|
|
4599c2d96f | ||
|
|
14a8067aea | ||
|
|
d719e15cf8 | ||
|
|
7814cd0e82 | ||
|
|
469cdfb95e | ||
|
|
e90c20bfc5 | ||
|
|
fc9dd1ee9b | ||
|
|
c85761f92e | ||
|
|
c342b478c1 | ||
|
|
fc6772a124 | ||
|
|
743b6283c0 | ||
|
|
3562b0e730 | ||
|
|
26a356a158 | ||
|
|
a3ebf0259e | ||
|
|
df097bfc81 | ||
|
|
765c546c20 | ||
|
|
13bdcdad03 | ||
|
|
b083a66afd | ||
|
|
4115c3d4df | ||
|
|
c63eaa5a12 | ||
|
|
531cb9281c | ||
|
|
bff796d574 | ||
|
|
f7d1b2ea1d | ||
|
|
9a620545b4 | ||
|
|
6e7f5302e3 | ||
|
|
ec787e7cf5 | ||
|
|
8ef48f3608 | ||
|
|
2bddedb96c | ||
|
|
5d39748a28 | ||
|
|
1a9fd61ec3 | ||
|
|
a9a1dc4ad3 | ||
|
|
aab9fdc101 | ||
|
|
21029f5f72 | ||
|
|
64ffdf4f32 | ||
|
|
68c85dd812 | ||
|
|
fd84dd0320 | ||
|
|
7df631b799 | ||
|
|
3d45a8d70f | ||
|
|
af387dc48a | ||
|
|
2978e3b8d1 | ||
|
|
32794a134c | ||
|
|
13459ef238 | ||
|
|
ffcc29796c | ||
|
|
79298ddf6c | ||
|
|
e0917110e1 | ||
|
|
1de8af79f0 | ||
|
|
5fe307a852 | ||
|
|
08bfa8199d | ||
|
|
2c94757947 | ||
|
|
85837531de | ||
|
|
3360ee33f1 | ||
|
|
d882007611 | ||
|
|
bd9116e1cf | ||
|
|
5e54c7bf61 | ||
|
|
bf6f3c0691 | ||
|
|
7eb893bcc9 | ||
|
|
52f05764f4 | ||
|
|
4900b9533c | ||
|
|
ced4c1ce1f | ||
|
|
05d6f5004d | ||
|
|
cdbbb3a957 | ||
|
|
457af73f88 | ||
|
|
6be3d89176 | ||
|
|
9267cb3918 | ||
|
|
f55d1a4a62 | ||
|
|
cc0ffe6fb2 | ||
|
|
acca6bfe47 | ||
|
|
8148f860e5 | ||
|
|
f0e9503354 | ||
|
|
4f779c9af0 | ||
|
|
202152bbf0 | ||
|
|
d55b9dcda0 | ||
|
|
6f6f2e4abc | ||
|
|
c3c21a26e0 | ||
|
|
b62f7b4daa | ||
|
|
1e61dd9832 | ||
|
|
5e2fd369fe | ||
|
|
a9bac3f1cb | ||
|
|
f3cc5b4e2c | ||
|
|
7f2d4991c5 | ||
|
|
c6667c2307 | ||
|
|
a386e1907f | ||
|
|
8ddd3a336e | ||
|
|
2a223671b9 | ||
|
|
1f8ddf4176 | ||
|
|
82ea78edbb | ||
|
|
a85f399b29 | ||
|
|
be86071f4f | ||
|
|
d9603ffdb5 | ||
|
|
87d4c78c67 | ||
|
|
da337dfd98 | ||
|
|
e0c75e30af | ||
|
|
bf0389fe99 | ||
|
|
7635ac1d90 | ||
|
|
179db56473 | ||
|
|
bc4cfa0265 | ||
|
|
3ba04c366c | ||
|
|
52bcb9912b | ||
|
|
5769fe2211 | ||
|
|
04a799e7f6 | ||
|
|
ed8d5fb7a8 | ||
|
|
8038be3023 | ||
|
|
511fd1df4e | ||
|
|
0b63dbbc9a | ||
|
|
85290a619e | ||
|
|
04a68d623d | ||
|
|
0805a7314d | ||
|
|
7726840d75 | ||
|
|
9895188f24 | ||
|
|
fdca98a994 | ||
|
|
86994eefb9 | ||
|
|
59b5d75f6f | ||
|
|
27e4917c34 | ||
|
|
85795c69b9 | ||
|
|
de57766e21 | ||
|
|
f8fe4f03a9 | ||
|
|
d1796e4c68 | ||
|
|
6eef736615 | ||
|
|
8a134d7ee1 | ||
|
|
8bc7c1671d | ||
|
|
2b1a7919a3 | ||
|
|
035f8aa570 | ||
|
|
5876b7da10 | ||
|
|
f92e36e7d4 | ||
|
|
cbf96e8b45 | ||
|
|
4537e7d56c | ||
|
|
33faa383b0 | ||
|
|
782c40b5d3 | ||
|
|
224ca9997a | ||
|
|
21effc7f21 | ||
|
|
cc1f7fe682 | ||
|
|
f619b8702c | ||
|
|
b4c17583b6 | ||
|
|
ff308a190c | ||
|
|
c5f3b83cdf | ||
|
|
03c81d8cc9 | ||
|
|
3c4c9ca555 | ||
|
|
19bade53a0 | ||
|
|
e3e6f484ea | ||
|
|
74adbf4790 | ||
|
|
65c175f977 | ||
|
|
351086110c | ||
|
|
5bc83206b2 | ||
|
|
8aa226d6ca | ||
|
|
02310df32f | ||
|
|
ffdd65f74d | ||
|
|
90b346b75a | ||
|
|
3619dafd47 | ||
|
|
12491b2235 | ||
|
|
2dd8151138 | ||
|
|
8c033fe878 | ||
|
|
f6d2384fa5 | ||
|
|
2621359a61 | ||
|
|
9f8c521659 | ||
|
|
2ffe2f510d | ||
|
|
f4d48fdf90 | ||
|
|
66bb7fce6a | ||
|
|
42636b29e8 | ||
|
|
e0cb50aaea | ||
|
|
bd9fe79f2a | ||
|
|
b90c9e3afa | ||
|
|
f2371d1c7e | ||
|
|
01f52c1e2f | ||
|
|
8d1d6303d3 | ||
|
|
bf05c67966 | ||
|
|
4102b2298d | ||
|
|
2171fc59eb | ||
|
|
8389d19545 | ||
|
|
5fe672884e | ||
|
|
9481d80e5f | ||
|
|
3f309f58bb | ||
|
|
004bba80ae | ||
|
|
0fc969cf00 | ||
|
|
5c29b1072b | ||
|
|
691db71d12 | ||
|
|
07265446cf | ||
|
|
86c9658318 | ||
|
|
8c584d7cfc | ||
|
|
f42f53b375 | ||
|
|
8c6c43429d | ||
|
|
a4c6e4871e | ||
|
|
f17cdc8fd0 | ||
|
|
3506cfc97d | ||
|
|
7d8a71eaf6 | ||
|
|
eeac21e2aa | ||
|
|
eb58fdfa00 | ||
|
|
44288a202d | ||
|
|
3196f8a0a4 | ||
|
|
63c12c7984 | ||
|
|
9a716efd37 | ||
|
|
136b6f7c46 | ||
|
|
63d8e86725 | ||
|
|
6cfb92faf1 | ||
|
|
f47d6d1d93 | ||
|
|
556126191d | ||
|
|
054eb5fd6c | ||
|
|
2f2db32e8c | ||
|
|
c9ab6316c7 | ||
|
|
7a3ba1a85a | ||
|
|
8a413f279a | ||
|
|
9a323a663e | ||
|
|
bb37f8e7ef | ||
|
|
623804ade5 | ||
|
|
38c7082760 | ||
|
|
2eb7cbf84e | ||
|
|
3d6f61bb56 | ||
|
|
01fdbe19d7 | ||
|
|
4ecfdfce27 | ||
|
|
e198c43ce2 | ||
|
|
ee7c27d280 | ||
|
|
9a6886ad71 | ||
|
|
019075d927 | ||
|
|
6131c74016 | ||
|
|
52e10aca4c | ||
|
|
4b7192edfa | ||
|
|
ac5d0e551b | ||
|
|
d04d1d608d | ||
|
|
46e37bfe7e | ||
|
|
e9a2a8c51e | ||
|
|
e453be092b | ||
|
|
ced4d26d20 | ||
|
|
683137451b | ||
|
|
61af37117d | ||
|
|
fb8a6119f9 | ||
|
|
62513e7a04 | ||
|
|
007a6f8a59 | ||
|
|
aa0970913c | ||
|
|
4ae471a7b9 | ||
|
|
f19d654e87 | ||
|
|
056d6d81d9 | ||
|
|
c20cf01f6b | ||
|
|
4a8df8954e | ||
|
|
f49ea13da5 | ||
|
|
06d6778c45 | ||
|
|
7348da9aff | ||
|
|
9a6d328b47 | ||
|
|
0eb28c094d | ||
|
|
c9ecf551bf | ||
|
|
d0a809c8a2 | ||
|
|
c2ecba402b | ||
|
|
9f1941e974 | ||
|
|
113cb05e91 | ||
|
|
ebcf8eaf4d | ||
|
|
5b510a9552 | ||
|
|
afdda092bc | ||
|
|
6858113d54 | ||
|
|
4283bc237a | ||
|
|
9b6474983d | ||
|
|
c1f037c15d | ||
|
|
5f26b75039 | ||
|
|
a6c9e7ed0a | ||
|
|
bfa2757156 | ||
|
|
3c0204af03 | ||
|
|
4e9116f3f9 | ||
|
|
9ef9156d7e | ||
|
|
9828f9d267 | ||
|
|
536e9ef21e | ||
|
|
8e1e9a104b | ||
|
|
2ca240625d | ||
|
|
4e33eeaf85 | ||
|
|
f40c9518c9 | ||
|
|
09b99fe4a4 | ||
|
|
0f0fc1645a | ||
|
|
c5279ae484 | ||
|
|
614f9ced6f | ||
|
|
12e35d8d04 | ||
|
|
8f703cd03b | ||
|
|
3ec9b9a4f7 | ||
|
|
4de1a97144 | ||
|
|
90ecc5fd2e | ||
|
|
b0fd423863 | ||
|
|
4f583a86f2 | ||
|
|
fcdb4ec546 | ||
|
|
874da44a79 | ||
|
|
b138891642 | ||
|
|
431f821039 | ||
|
|
766c0dd608 | ||
|
|
9970bc5751 | ||
|
|
f153c143e3 | ||
|
|
188d69ad49 | ||
|
|
22290545fd | ||
|
|
53970fe277 | ||
|
|
0e77f3cd91 | ||
|
|
7297122fc5 | ||
|
|
eb6c2e72a9 | ||
|
|
0ac3673f03 | ||
|
|
0c662d8b66 | ||
|
|
d21d130c9b | ||
|
|
c0045b2e1e | ||
|
|
f015246e94 | ||
|
|
85fd27fc48 | ||
|
|
656cb361c8 | ||
|
|
0edc47cf65 | ||
|
|
d54e3dd25a | ||
|
|
3bba1e9d6a | ||
|
|
21d693290a | ||
|
|
06878b3135 | ||
|
|
b12b6ab996 | ||
|
|
f8f77e88a1 | ||
|
|
d9c6281862 | ||
|
|
c5cff14c2f | ||
|
|
56ce7f674b | ||
|
|
8ce3e63a58 | ||
|
|
57886feaab | ||
|
|
2ef6807ac0 | ||
|
|
fe80ca398b | ||
|
|
fc6aa66b4a | ||
|
|
290be34b28 | ||
|
|
163c3a5eb6 | ||
|
|
bf39d6c7ef | ||
|
|
930e96b649 | ||
|
|
cfb4833373 | ||
|
|
e4b2449880 | ||
|
|
750cbac9db | ||
|
|
7a7dcefe74 | ||
|
|
5f3d7be45c | ||
|
|
249f37fd15 | ||
|
|
a5257fad86 | ||
|
|
72af6df31f | ||
|
|
de0a2aca32 | ||
|
|
a29048358f |
54 changed files with 5651 additions and 2118 deletions
26
.github/workflows/build.yml
vendored
26
.github/workflows/build.yml
vendored
|
|
@ -1,25 +1,27 @@
|
||||||
name: Build APK
|
name: Build APK
|
||||||
|
|
||||||
on: [push]
|
on: [push]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build and push
|
name: Build and push
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- uses: actions/setup-java@v3
|
- name: Setup Java
|
||||||
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: "temurin"
|
distribution: "temurin"
|
||||||
java-version: "17"
|
java-version: "17"
|
||||||
|
|
||||||
- name: "Grant execution permission"
|
- name: Make Gradle executable
|
||||||
run: chmod +x ./gradlew
|
run: chmod +x ./gradlew
|
||||||
|
|
||||||
- name: "Build APKs"
|
- name: Build APKs
|
||||||
run: |
|
run: |
|
||||||
# Retry the Gradle build up to 3 times to mitigate transient cache/service errors
|
# Retry the Gradle build up to 3 times to mitigate transient cache/service errors
|
||||||
ATTEMPT=0
|
ATTEMPT=0
|
||||||
|
|
@ -35,30 +37,30 @@ jobs:
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: "Setup directories"
|
- name: Setup directories
|
||||||
run: |
|
run:
|
||||||
mkdir -p /tmp/ShiggyXposedbuild/apks
|
mkdir -p /tmp/ShiggyXposedbuild/apks
|
||||||
mkdir -p /tmp/ShiggyXposedbuild/tools
|
mkdir -p /tmp/ShiggyXposedbuild/tools
|
||||||
|
|
||||||
- name: "Move debug APK"
|
- name: Move debug APK
|
||||||
run: mv ./app/build/outputs/apk/debug/app-debug.apk /tmp/ShiggyXposedbuild/apks
|
run: mv ./app/build/outputs/apk/debug/app-debug.apk /tmp/ShiggyXposedbuild/apks
|
||||||
|
|
||||||
- name: "Download uber-apk-signer"
|
- 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/ShiggyXposedbuild/tools/uber-apk-signer.jar
|
run: wget -nv "https://github.com/patrickfav/uber-apk-signer/releases/download/v1.2.1/uber-apk-signer-1.2.1.jar" -O /tmp/ShiggyXposedbuild/tools/uber-apk-signer.jar
|
||||||
|
|
||||||
- name: "Sign release APK"
|
- name: Sign release APK
|
||||||
run: java -jar /tmp/ShiggyXposedbuild/tools/uber-apk-signer.jar --apks ./app/build/outputs/apk/release/app-release-unsigned.apk --out /tmp/ShiggyXposedbuild/apks/
|
run: java -jar /tmp/ShiggyXposedbuild/tools/uber-apk-signer.jar --apks ./app/build/outputs/apk/release/app-release-unsigned.apk --out /tmp/ShiggyXposedbuild/apks/
|
||||||
|
|
||||||
- name: "Rename release APK"
|
- name: Rename release APK
|
||||||
run: mv /tmp/ShiggyXposedbuild/apks/app-release-aligned-debugSigned.apk /tmp/ShiggyXposedbuild/apks/app-release.apk
|
run: mv /tmp/ShiggyXposedbuild/apks/app-release-aligned-debugSigned.apk /tmp/ShiggyXposedbuild/apks/app-release.apk
|
||||||
|
|
||||||
- name: "Upload debug APK"
|
- name: Upload debug APK
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: app-debug
|
name: app-debug
|
||||||
path: /tmp/ShiggyXposedbuild/apks/app-debug.apk
|
path: /tmp/ShiggyXposedbuild/apks/app-debug.apk
|
||||||
|
|
||||||
- name: "Upload release APK"
|
- name: Upload All APKs
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: app-release
|
name: app-release
|
||||||
|
|
|
||||||
128
.github/workflows/release.yml
vendored
128
.github/workflows/release.yml
vendored
|
|
@ -1,128 +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@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Setup JDK 17
|
|
||||||
uses: actions/setup-java@v3
|
|
||||||
with:
|
|
||||||
distribution: "temurin"
|
|
||||||
java-version: 17
|
|
||||||
|
|
||||||
- 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: |
|
|
||||||
# Ensure keystore secret is present
|
|
||||||
if [ -z "${{ secrets.keystore }}" ]; then
|
|
||||||
echo "Keystore secret missing, aborting"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "${{ secrets.keystore }}" | base64 -d > $GITHUB_WORKSPACE/signing-key.jks
|
|
||||||
chmod +x ./gradlew
|
|
||||||
|
|
||||||
# Retry gradle up to 3 times to mitigate transient network/cache service issues
|
|
||||||
ATTEMPT=0
|
|
||||||
until [ $ATTEMPT -ge 3 ]
|
|
||||||
do
|
|
||||||
./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 }} --no-daemon --stacktrace && break
|
|
||||||
|
|
||||||
ATTEMPT=$((ATTEMPT+1))
|
|
||||||
echo "Gradle build failed, retrying ($ATTEMPT/3)..."
|
|
||||||
sleep 5
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ $ATTEMPT -ge 3 ]; then
|
|
||||||
echo "Gradle build failed after 3 attempts"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Release
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
echo "Locating built APK(s) under app/build/outputs..."
|
|
||||||
# Find APK files produced by the build in common output locations
|
|
||||||
apk_paths=$(find app/build/outputs -type f -name "*.apk" 2>/dev/null || true)
|
|
||||||
if [ -z "$apk_paths" ]; then
|
|
||||||
echo "ERROR: No APK files found under app/build/outputs"
|
|
||||||
echo "Diagnostics: top-level listing of app/build/outputs:"
|
|
||||||
ls -la app/build/outputs || true
|
|
||||||
echo "Diagnostics: recursive listing (files only):"
|
|
||||||
find app/build/outputs -type f -maxdepth 6 -print || true
|
|
||||||
echo "Failing the job so you can inspect the build logs and diagnostics above."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
# Prepare a directory to collect APKs for the release
|
|
||||||
mkdir -p release-apks
|
|
||||||
echo "Copying found APK(s) to release-apks/ for upload:"
|
|
||||||
for apk in $apk_paths; do
|
|
||||||
echo " - $apk"
|
|
||||||
cp "$apk" "release-apks/$(basename "$apk")"
|
|
||||||
done
|
|
||||||
# Keep backward compatibility: if exactly one APK found, copy it to the root as app-release.apk
|
|
||||||
apk_count=$(echo "$apk_paths" | wc -w)
|
|
||||||
if [ "$apk_count" -eq 1 ]; then
|
|
||||||
single_apk=$(echo "$apk_paths")
|
|
||||||
cp "$single_apk" ./app-release.apk
|
|
||||||
echo "Single APK copied to ./app-release.apk"
|
|
||||||
else
|
|
||||||
echo "Multiple APKs found ($apk_count). All copied to release-apks/"
|
|
||||||
fi
|
|
||||||
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"
|
|
||||||
# Create the GitHub release including all APK(s) we collected (debug + release)
|
|
||||||
gh release create "$tag" \
|
|
||||||
--title "${{ github.event.inputs.versionName }}" \
|
|
||||||
--generate-notes \
|
|
||||||
release-apks/*
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -15,3 +15,4 @@ build
|
||||||
local.properties
|
local.properties
|
||||||
app/release/release/
|
app/release/release/
|
||||||
release
|
release
|
||||||
|
release.keystore
|
||||||
|
|
|
||||||
3
.idea/.gitignore
generated
vendored
Normal file
3
.idea/.gitignore
generated
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
40
README.md
Normal file
40
README.md
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# GoonXposed
|
||||||
|
|
||||||
|
GoonXposed is a root Xposed module that injects the GoonCord Discord mobile client modifications into the official Discord application.
|
||||||
|
|
||||||
|
## Info
|
||||||
|
* Injects GoonCord client into the Discord mobile app.
|
||||||
|
* Built in Kotlin.
|
||||||
|
* Material 3 like Shake menu with some discord popup accents
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
To use this module, your device must meet the following requirements:
|
||||||
|
* **Root Access**: Provided by Magisk, KernelSU, KernelSUNext, or Sukisu Ultra.
|
||||||
|
* **Xposed Framework**: Use JingMatrix Fork of [LSPosed](https://github.com/JingMatrix/LSPosed).
|
||||||
|
* **Target Application**: An official Discord application (stable, beta, or alpha version).
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
1. Download the latest version of the module from the [Releases](https://github.com/OTKUSteyler/GoonXposed/releases) page.
|
||||||
|
2. Install the downloaded APK file on your device.
|
||||||
|
3. Open the LSPosed manager application.
|
||||||
|
4. Go to the **Modules** section and enable **GoonXposed**.
|
||||||
|
5. In the module's settings, set the scope to include the **Discord** application.
|
||||||
|
6. Reboot your device for the changes to take effect.
|
||||||
|
|
||||||
|
The project is built with Kotlin and inherits its foundational structure from KettuXposed.
|
||||||
|
|
||||||
|
# Troubleshooting
|
||||||
|
|
||||||
|
- Module not working: Ensure the module is enabled in LSPosed and that Discord is correctly selected in its scope. if still nothing, head to our [discord support server](https://discord.gg/nQykFF9Ud6)
|
||||||
|
- Any other issues: Same server as above, listing them all is not worth it.
|
||||||
|
|
||||||
|
# Thanks & Credits
|
||||||
|
|
||||||
|
This project was made possible thanks to the work of:
|
||||||
|
|
||||||
|
· The LSPosed Team for the framework.
|
||||||
|
· JingMatrix for their LSPosed fork.
|
||||||
|
· [cocobo1](https://codeberg.org/cocobo1) for adjusting the Xposed module for Kettu client mod.
|
||||||
|
· [Revenge Team](https://github.com/revenge-mod) for making a really good Xposed Module to base of
|
||||||
|
|
||||||
|

|
||||||
|
|
@ -10,10 +10,22 @@ android {
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "GoonXposed.xposed"
|
applicationId = "GoonXposed.xposed"
|
||||||
minSdk = 22
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 1051
|
versionCode = 1358
|
||||||
versionName = "1.0.51"
|
versionName = "1.3.8"
|
||||||
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
val keystoreFile = file("../release.keystore")
|
||||||
|
if (keystoreFile.exists()) {
|
||||||
|
storeFile = keystoreFile
|
||||||
|
storePassword = "goonxposed"
|
||||||
|
keyAlias = "goonxposed"
|
||||||
|
keyPassword = "goonxposed"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|
@ -24,35 +36,38 @@ android {
|
||||||
}
|
}
|
||||||
release {
|
release {
|
||||||
isDebuggable = false
|
isDebuggable = false
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = true
|
||||||
|
val keystoreFile = file("../release.keystore")
|
||||||
|
if (keystoreFile.exists()) {
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_1_8
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "1.8"
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
}
|
}
|
||||||
kotlinOptions { jvmTarget = "17" }
|
|
||||||
buildFeatures { buildConfig = true }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
repositories {
|
tasks.withType<org.gradle.api.tasks.compile.JavaCompile>().configureEach {
|
||||||
mavenCentral()
|
options.compilerArgs.add("-Xlint:-options")
|
||||||
google()
|
|
||||||
maven("https://jitpack.io")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
compileOnly("de.robv.android.xposed:api:82")
|
compileOnly("de.robv.android.xposed:api:82")
|
||||||
|
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0")
|
implementation("androidx.core:core-ktx:1.17.0")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1")
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
|
||||||
implementation("org.jetbrains.kotlin:kotlin-reflect: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-core:3.3.1")
|
||||||
implementation("io.ktor:ktor-client-cio:3.3.0")
|
implementation("io.ktor:ktor-client-cio:3.3.1")
|
||||||
implementation("androidx.core:core-ktx:1.12.0")
|
implementation("com.google.android.material:material:1.12.0")
|
||||||
implementation("androidx.appcompat:appcompat:1.6.1")
|
implementation("dev.rikka.shizuku:api:13.1.5")
|
||||||
implementation("com.google.android.material:material:1.11.0")
|
implementation("dev.rikka.shizuku:provider:13.1.5")
|
||||||
testImplementation("junit:junit:4.13.2")
|
|
||||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
|
||||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
<?xml version="1.0" encoding="utf-8" ?>
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
<manifest
|
<manifest
|
||||||
package="GoonXposed.xposed"
|
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@ class Constants {
|
||||||
const val CACHE_DIR = "cache/pyoncord"
|
const val CACHE_DIR = "cache/pyoncord"
|
||||||
const val MAIN_SCRIPT_FILE = "bundle.js"
|
const val MAIN_SCRIPT_FILE = "bundle.js"
|
||||||
|
|
||||||
const val LOG_TAG = "GoonXposed"
|
|
||||||
|
const val LOG_TAG = "Goon"
|
||||||
|
|
||||||
const val LOADER_NAME = "GoonXposed"
|
const val LOADER_NAME = "GoonXposed"
|
||||||
|
|
||||||
108
app/src/main/kotlin/ShiggyXposed/xposed/xposed/Main.kt
Normal file
108
app/src/main/kotlin/ShiggyXposed/xposed/xposed/Main.kt
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
package GoonXposed.xposed
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.ContextWrapper
|
||||||
|
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 GoonXposed.xposed.Utils.Log
|
||||||
|
import GoonXposed.xposed.modules.*
|
||||||
|
import GoonXposed.xposed.modules.appearance.FontsModule
|
||||||
|
import GoonXposed.xposed.modules.appearance.SysColorsModule
|
||||||
|
import GoonXposed.xposed.modules.appearance.ThemesModule
|
||||||
|
import GoonXposed.xposed.modules.bridge.AdditionalBridgeMethodsModule
|
||||||
|
import GoonXposed.xposed.modules.bridge.BridgeModule
|
||||||
|
import GoonXposed.xposed.modules.no_track.BlockCrashReportingModule
|
||||||
|
import GoonXposed.xposed.modules.no_track.BlockDeepLinksTrackingModule
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
|
||||||
|
object HookStateHolder {
|
||||||
|
/**
|
||||||
|
* Whether all hooks are completed, and we are ready to load the JS bundle.
|
||||||
|
*/
|
||||||
|
val readyDeferred = CompletableDeferred<Unit>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether we have successfully received a [Context] yet.
|
||||||
|
* Sometimes the app process is recreated and Xposed hooks way too late for us to get [Context] from [ContextWrapper.attachBaseContext].
|
||||||
|
* But since Xposed hooks before [Activity.onCreate], we can still get it from there and still initialize properly.
|
||||||
|
*/
|
||||||
|
@Volatile
|
||||||
|
var gotContext = false
|
||||||
|
}
|
||||||
|
|
||||||
|
class Main : Module(), IXposedHookLoadPackage, IXposedHookZygoteInit {
|
||||||
|
private var hooked = false
|
||||||
|
private val modules = mutableListOf(
|
||||||
|
HookScriptLoaderModule,
|
||||||
|
BridgeModule,
|
||||||
|
AdditionalBridgeMethodsModule,
|
||||||
|
PluginsModule(),
|
||||||
|
UpdaterModule,
|
||||||
|
FixResourcesModule,
|
||||||
|
BlockDeepLinksTrackingModule,
|
||||||
|
BlockCrashReportingModule,
|
||||||
|
LogBoxModule,
|
||||||
|
CacheModule,
|
||||||
|
PerfPatchesModule,
|
||||||
|
FontsModule,
|
||||||
|
ThemesModule,
|
||||||
|
SysColorsModule
|
||||||
|
)
|
||||||
|
|
||||||
|
init {
|
||||||
|
modules += PayloadGlobalModule(modules)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun initZygote(startupParam: IXposedHookZygoteInit.StartupParam) {
|
||||||
|
for (module in modules) module.onInit(startupParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handleLoadPackage(param: XC_LoadPackage.LoadPackageParam) = with(param) {
|
||||||
|
if (hooked) return
|
||||||
|
|
||||||
|
val reactActivity = classLoader.loadClass(Constants.TARGET_ACTIVITY)
|
||||||
|
|
||||||
|
ContextWrapper::class.java.hookMethod("attachBaseContext", Context::class.java) {
|
||||||
|
after {
|
||||||
|
val ctx = args[0] as Context
|
||||||
|
HookStateHolder.gotContext = true
|
||||||
|
Log.i("Received Context")
|
||||||
|
this@Main.onContext(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reactActivity.hookMethod("onCreate", Bundle::class.java) {
|
||||||
|
after {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this@Main.onLoad(param)
|
||||||
|
|
||||||
|
hooked = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) {
|
||||||
|
for (module in modules) module.onLoad(packageParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onContext(context: Context) {
|
||||||
|
for (module in modules) module.onContext(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivity(activity: Activity) {
|
||||||
|
for (module in modules) module.onActivity(activity)
|
||||||
|
}
|
||||||
|
}
|
||||||
161
app/src/main/kotlin/ShiggyXposed/xposed/xposed/Module.kt
Normal file
161
app/src/main/kotlin/ShiggyXposed/xposed/xposed/Module.kt
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
package GoonXposed.xposed
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import de.robv.android.xposed.IXposedHookZygoteInit
|
||||||
|
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 kotlinx.serialization.json.JsonObjectBuilder
|
||||||
|
import java.io.File
|
||||||
|
import java.lang.reflect.Method
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for every Xposed side module.
|
||||||
|
*
|
||||||
|
* The [Main] loader forwards the various [XC_LoadPackage] lifecycle callbacks to every
|
||||||
|
* registered [Module] instance. Modules may override any subset of the hooks below.
|
||||||
|
*/
|
||||||
|
open class Module {
|
||||||
|
open fun onInit(startupParam: IXposedHookZygoteInit.StartupParam) {}
|
||||||
|
|
||||||
|
open fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) {}
|
||||||
|
|
||||||
|
open fun onContext(context: Context) {}
|
||||||
|
|
||||||
|
open fun onActivity(activity: Activity) {}
|
||||||
|
|
||||||
|
@Deprecated("This method is deprecated in the parent class")
|
||||||
|
open fun buildPayload(builder: JsonObjectBuilder) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime wrapper around an [XC_MethodHook.MethodHookParam] used as the receiver of every
|
||||||
|
* `before`/`after` hook block, exposing [thisObject], [args], [result] and [param] directly.
|
||||||
|
*/
|
||||||
|
class HookScope(val param: XC_MethodHook.MethodHookParam) {
|
||||||
|
val thisObject: Any?
|
||||||
|
get() = param.thisObject
|
||||||
|
|
||||||
|
val args: Array<Any?>
|
||||||
|
get() = param.args
|
||||||
|
|
||||||
|
var result: Any?
|
||||||
|
get() = param.result
|
||||||
|
set(value) {
|
||||||
|
param.result = value
|
||||||
|
}
|
||||||
|
|
||||||
|
var throwable: Throwable?
|
||||||
|
get() = param.throwable
|
||||||
|
set(value) {
|
||||||
|
param.throwable = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DSL builder for lazily constructed [XC_MethodHook] instances.
|
||||||
|
*
|
||||||
|
* ```kotlin
|
||||||
|
* val hook = MethodHookBuilder().run {
|
||||||
|
* before { result = null }
|
||||||
|
* after { Log.i("done") }
|
||||||
|
* build()
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
class MethodHookBuilder {
|
||||||
|
private val beforeCallbacks = mutableListOf<HookScope.() -> Unit>()
|
||||||
|
private val afterCallbacks = mutableListOf<HookScope.() -> Unit>()
|
||||||
|
|
||||||
|
fun before(callback: HookScope.() -> Unit) {
|
||||||
|
beforeCallbacks += callback
|
||||||
|
}
|
||||||
|
|
||||||
|
fun after(callback: HookScope.() -> Unit) {
|
||||||
|
afterCallbacks += callback
|
||||||
|
}
|
||||||
|
|
||||||
|
fun build(): XC_MethodHook = object : XC_MethodHook() {
|
||||||
|
override fun beforeHookedMethod(param: MethodHookParam) {
|
||||||
|
val scope = HookScope(param)
|
||||||
|
beforeCallbacks.forEach { scope.run(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun afterHookedMethod(param: MethodHookParam) {
|
||||||
|
val scope = HookScope(param)
|
||||||
|
afterCallbacks.forEach { scope.run(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun from(block: MethodHookBuilder.() -> Unit): XC_MethodHook = MethodHookBuilder().run {
|
||||||
|
block()
|
||||||
|
build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ClassLoader.safeLoadClass(name: String): Class<*>? = try {
|
||||||
|
loadClass(name)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Class<*>.method(name: String, vararg parameterTypes: Class<*>): Method =
|
||||||
|
XposedHelpers.findMethodExact(this, name, *parameterTypes)
|
||||||
|
|
||||||
|
fun Method.hook(block: MethodHookBuilder.() -> Unit) {
|
||||||
|
val builder = MethodHookBuilder()
|
||||||
|
builder.block()
|
||||||
|
XposedBridge.hookMethod(this, builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Method.hook(hook: XC_MethodHook) {
|
||||||
|
XposedBridge.hookMethod(this, hook)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Class<*>.hookMethod(
|
||||||
|
name: String,
|
||||||
|
vararg parameterTypes: Class<*>,
|
||||||
|
block: MethodHookBuilder.() -> Unit
|
||||||
|
): Method {
|
||||||
|
val method = method(name, *parameterTypes)
|
||||||
|
method.hook(block)
|
||||||
|
return method
|
||||||
|
}
|
||||||
|
|
||||||
|
fun File.asDir(): File {
|
||||||
|
if (!exists() || !isDirectory) mkdirs()
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun File.asFile(): File {
|
||||||
|
parentFile?.mkdirs()
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AppInfo(
|
||||||
|
val name: String,
|
||||||
|
val packageName: String,
|
||||||
|
val version: String,
|
||||||
|
val versionCode: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
fun Context.getAppInfo(): AppInfo {
|
||||||
|
val label = applicationInfo.loadLabel(packageManager).toString()
|
||||||
|
val packageInfo = try {
|
||||||
|
packageManager.getPackageInfo(packageName, 0)
|
||||||
|
} catch (e: PackageManager.NameNotFoundException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
return AppInfo(
|
||||||
|
name = label,
|
||||||
|
packageName = packageName,
|
||||||
|
version = packageInfo?.versionName ?: "",
|
||||||
|
versionCode = packageInfo?.versionCode ?: 0
|
||||||
|
)
|
||||||
|
}
|
||||||
56
app/src/main/kotlin/ShiggyXposed/xposed/xposed/Utils.kt
Normal file
56
app/src/main/kotlin/ShiggyXposed/xposed/xposed/Utils.kt
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
package GoonXposed.xposed
|
||||||
|
|
||||||
|
import android.app.AlertDialog
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import de.robv.android.xposed.XposedHelpers
|
||||||
|
import GoonXposed.xposed.modules.UpdaterModule
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
|
class Utils {
|
||||||
|
companion object {
|
||||||
|
val JSON = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
fun reloadApp() {
|
||||||
|
val activityThread = XposedHelpers.findClass("android.app.ActivityThread", null)
|
||||||
|
val application =
|
||||||
|
XposedHelpers.callStaticMethod(activityThread, "currentApplication") as? Application ?: return
|
||||||
|
val intent = application.packageManager.getLaunchIntentForPackage(application.packageName)
|
||||||
|
application.startActivity(Intent.makeRestartActivityTask(intent!!.component))
|
||||||
|
exitProcess(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showRecoveryAlert(context: Context) {
|
||||||
|
AlertDialog.Builder(context).setTitle("GoonCord 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object Log {
|
||||||
|
fun e(msg: String) = android.util.Log.e(Constants.LOG_TAG, msg)
|
||||||
|
fun e(msg: String, throwable: Throwable) = android.util.Log.e(Constants.LOG_TAG, msg, throwable)
|
||||||
|
fun i(msg: String) = android.util.Log.i(Constants.LOG_TAG, msg)
|
||||||
|
fun i(msg: String, throwable: Throwable) = android.util.Log.i(Constants.LOG_TAG, msg, throwable)
|
||||||
|
fun w(msg: String) = android.util.Log.w(Constants.LOG_TAG, msg)
|
||||||
|
fun w(msg: String, throwable: Throwable) = android.util.Log.w(Constants.LOG_TAG, msg, throwable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,273 @@
|
||||||
|
package GoonXposed.xposed.modules
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.AtomicFile
|
||||||
|
import GoonXposed.xposed.Module
|
||||||
|
import GoonXposed.xposed.Utils.Log
|
||||||
|
import GoonXposed.xposed.asDir
|
||||||
|
import GoonXposed.xposed.asFile
|
||||||
|
import GoonXposed.xposed.getAppInfo
|
||||||
|
import GoonXposed.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
|
||||||
|
*
|
||||||
|
* - `Shiggy.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.
|
||||||
|
*
|
||||||
|
* - `Shiggy.caches.modules.write(blacklist: number[], finds: { [filter: string]: { [id: string]: number } | null }) : void`
|
||||||
|
* - Writes the modules cache.
|
||||||
|
*
|
||||||
|
* ### Assets cache
|
||||||
|
*
|
||||||
|
* - `Shiggy.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.
|
||||||
|
*
|
||||||
|
* - `Shiggy.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.
|
||||||
|
*/
|
||||||
|
object CacheModule : Module() {
|
||||||
|
|
||||||
|
private const val CACHE_DIR = "Goon"
|
||||||
|
|
||||||
|
private const val MODULES_CACHE_PREFIX = "modules"
|
||||||
|
private const val ASSETS_CACHE_PREFIX = "assets"
|
||||||
|
|
||||||
|
|
||||||
|
private lateinit var modulesCache: ModulesCache
|
||||||
|
private lateinit var assetsCache: AssetsCache
|
||||||
|
|
||||||
|
override fun onContext(context: Context) = with(context) {
|
||||||
|
val ShiggyCacheDir = File(cacheDir, CACHE_DIR).apply { asDir() }
|
||||||
|
val (_, _, _, versionCode) = getAppInfo()
|
||||||
|
|
||||||
|
val modulesCacheFile = File(
|
||||||
|
ShiggyCacheDir, "$MODULES_CACHE_PREFIX.$versionCode"
|
||||||
|
).apply { asFile() }
|
||||||
|
|
||||||
|
val assetsCacheFile = File(
|
||||||
|
ShiggyCacheDir, "$ASSETS_CACHE_PREFIX.$versionCode"
|
||||||
|
).apply { asFile() }
|
||||||
|
|
||||||
|
BridgeModule.registerMethod("Shiggy.caches.modules.read") {
|
||||||
|
if (::modulesCache.isInitialized) modulesCache.toMap() else ModulesCache.loadFromFileOrNull(modulesCacheFile)
|
||||||
|
?.let {
|
||||||
|
modulesCache = it
|
||||||
|
it.toMap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BridgeModule.registerMethod("Shiggy.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("Shiggy.caches.assets.read") {
|
||||||
|
if (::assetsCache.isInitialized) assetsCache.toMap() else AssetsCache.loadFromFileOrNull(assetsCacheFile)
|
||||||
|
?.let {
|
||||||
|
assetsCache = it
|
||||||
|
it.toMap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BridgeModule.registerMethod("Shiggy.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
package GoonXposed.xposed.modules
|
||||||
|
|
||||||
|
import GoonXposed.xposed.hookMethod
|
||||||
|
import android.content.res.Resources
|
||||||
|
import de.robv.android.xposed.callbacks.XC_LoadPackage
|
||||||
|
import GoonXposed.xposed.Constants.Companion.TARGET_PACKAGE
|
||||||
|
import GoonXposed.xposed.Module
|
||||||
|
import GoonXposed.xposed.hookMethod
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hooks [Resources.getIdentifier] to fix resource package name mismatch.
|
||||||
|
*/
|
||||||
|
object FixResourcesModule : Module() {
|
||||||
|
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
|
||||||
|
if (packageName != TARGET_PACKAGE) {
|
||||||
|
Resources::class.java.hookMethod(
|
||||||
|
"getIdentifier", String::class.java, String::class.java, String::class.java
|
||||||
|
) {
|
||||||
|
before {
|
||||||
|
if (args[2] == packageName) args[2] = TARGET_PACKAGE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
package GoonXposed.xposed.modules
|
||||||
|
|
||||||
|
import android.content.res.AssetManager
|
||||||
|
import android.content.res.XModuleResources
|
||||||
|
import de.robv.android.xposed.IXposedHookZygoteInit
|
||||||
|
import de.robv.android.xposed.XposedBridge
|
||||||
|
import de.robv.android.xposed.callbacks.XC_LoadPackage
|
||||||
|
import GoonXposed.xposed.Constants
|
||||||
|
import GoonXposed.xposed.HookScope
|
||||||
|
import GoonXposed.xposed.HookStateHolder
|
||||||
|
import GoonXposed.xposed.Module
|
||||||
|
import GoonXposed.xposed.Utils.Log
|
||||||
|
import GoonXposed.xposed.asDir
|
||||||
|
import GoonXposed.xposed.asFile
|
||||||
|
import GoonXposed.xposed.hook
|
||||||
|
import GoonXposed.xposed.method
|
||||||
|
import GoonXposed.xposed.safeLoadClass
|
||||||
|
import GoonXposed.xposed.modules.HookScriptLoaderModule.PRELOADS_DIR
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import java.io.File
|
||||||
|
import java.lang.reflect.Method
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hooks React Native's script loading methods to load custom scripts and bundles.
|
||||||
|
*
|
||||||
|
* 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://Shiggy.bundle` from the module's assets.
|
||||||
|
*/
|
||||||
|
object HookScriptLoaderModule : Module() {
|
||||||
|
private lateinit var preloadsDir: File
|
||||||
|
private lateinit var mainScript: File
|
||||||
|
|
||||||
|
private lateinit var modulePath: String
|
||||||
|
private lateinit var resources: XModuleResources
|
||||||
|
|
||||||
|
const val PRELOADS_DIR = "preloads"
|
||||||
|
|
||||||
|
override fun onInit(startupParam: IXposedHookZygoteInit.StartupParam) {
|
||||||
|
this@HookScriptLoaderModule.modulePath = startupParam.modulePath
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
|
||||||
|
val cacheDir = File(appInfo.dataDir, Constants.CACHE_DIR).apply { asDir() }
|
||||||
|
val filesDir = File(appInfo.dataDir, Constants.FILES_DIR).apply { asDir() }
|
||||||
|
|
||||||
|
preloadsDir = File(filesDir, PRELOADS_DIR).apply { asDir() }
|
||||||
|
mainScript = File(cacheDir, Constants.MAIN_SCRIPT_FILE).apply { asFile() }
|
||||||
|
|
||||||
|
listOf(
|
||||||
|
"com.facebook.react.runtime.ReactInstance\$loadJSBundle$1",
|
||||||
|
"com.facebook.react.runtime.ReactInstance$1",
|
||||||
|
// TODO: Remove once Discord fully switches to Bridgeless
|
||||||
|
"com.facebook.react.bridge.CatalystInstanceImpl"
|
||||||
|
).mapNotNull { classLoader.safeLoadClass(it) }.forEach { hook(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hook(instance: Class<*>) = runCatching {
|
||||||
|
val loadScriptFromAssets = instance.method(
|
||||||
|
"loadScriptFromAssets", AssetManager::class.java, String::class.java, Boolean::class.javaPrimitiveType!!
|
||||||
|
)
|
||||||
|
|
||||||
|
val loadScriptFromFile = instance.method(
|
||||||
|
"loadScriptFromFile", String::class.java, String::class.java, Boolean::class.javaPrimitiveType!!
|
||||||
|
)
|
||||||
|
|
||||||
|
loadScriptFromAssets.hook {
|
||||||
|
before {
|
||||||
|
Log.i("Received call to loadScriptFromAssets: ${args[1]} (sync: ${args[2]})")
|
||||||
|
runCustomScripts(loadScriptFromFile, loadScriptFromAssets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadScriptFromFile.hook {
|
||||||
|
before {
|
||||||
|
Log.i("Received call to loadScriptFromFile: ${args[0]} (sync: ${args[2]})")
|
||||||
|
runCustomScripts(loadScriptFromFile, loadScriptFromAssets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure {
|
||||||
|
Log.e("Failed to hook script loading methods in ${instance.name}:", it)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun HookScope.runCustomScripts(loadScriptFromFile: Method, loadScriptFromAssets: Method) {
|
||||||
|
Log.i("Running custom scripts...")
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
val ready = async { HookStateHolder.readyDeferred.join() }
|
||||||
|
|
||||||
|
if (!mainScript.exists()) {
|
||||||
|
Log.i("Main script does not exist, downloading before load...")
|
||||||
|
val download = async { UpdaterModule.downloadScript().join() }
|
||||||
|
awaitAll(ready, download)
|
||||||
|
} else {
|
||||||
|
Log.i("Main script exists, updating in background...")
|
||||||
|
UpdaterModule.downloadScript()
|
||||||
|
ready.await()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val loadSynchronously = args[2]
|
||||||
|
val runScriptFile = { file: File ->
|
||||||
|
Log.i("Loading script: ${file.absolutePath}")
|
||||||
|
|
||||||
|
XposedBridge.invokeOriginalMethod(
|
||||||
|
loadScriptFromFile, thisObject, arrayOf(file.absolutePath, file.absolutePath, loadSynchronously)
|
||||||
|
)
|
||||||
|
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val disabledMarker = File(mainScript.parentFile, "${Constants.MAIN_SCRIPT_FILE}.disabled")
|
||||||
|
if (disabledMarker.exists()) {
|
||||||
|
Log.i("Script loading disabled by marker; skipping preloads, cached bundle and fallback")
|
||||||
|
} else {
|
||||||
|
preloadsDir.walk().filter { it.isFile }.forEach(runScriptFile)
|
||||||
|
|
||||||
|
if (mainScript.exists()) {
|
||||||
|
runScriptFile(mainScript)
|
||||||
|
} else {
|
||||||
|
Log.i("Main script does not exist, falling back")
|
||||||
|
|
||||||
|
if (!::resources.isInitialized) resources = XModuleResources.createInstance(modulePath, null)
|
||||||
|
|
||||||
|
XposedBridge.invokeOriginalMethod(
|
||||||
|
loadScriptFromAssets,
|
||||||
|
thisObject,
|
||||||
|
arrayOf(resources.assets, "assets://Shiggy.bundle", loadSynchronously)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.e("Unable to run scripts:", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,170 @@
|
||||||
|
package GoonXposed.xposed.modules.LogBox
|
||||||
|
|
||||||
|
import android.app.AlertDialog
|
||||||
|
import android.content.Context
|
||||||
|
import android.text.InputType
|
||||||
|
import android.widget.*
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
object CustomBundleDialog {
|
||||||
|
|
||||||
|
fun showCustomBundleDialog(context: Context) {
|
||||||
|
val colors = LogBoxTheme.getM3Colors(context)
|
||||||
|
val filesDir = LogBoxUtils.getPyoncordDirectory(context)
|
||||||
|
val configFile = File(filesDir, "loader.json")
|
||||||
|
var currentUrl: String? = null
|
||||||
|
var isEnabled = false
|
||||||
|
|
||||||
|
if (configFile.exists()) {
|
||||||
|
try {
|
||||||
|
val json = JSONObject(configFile.readText())
|
||||||
|
val customLoadUrl = json.optJSONObject("customLoadUrl")
|
||||||
|
if (customLoadUrl != null) {
|
||||||
|
isEnabled = customLoadUrl.optBoolean("enabled", false)
|
||||||
|
currentUrl = customLoadUrl.optString("url", "")
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val container = LogBoxComponents.createMenuContainer(context, colors)
|
||||||
|
container.addView(LogBoxComponents.createTitle(context, colors, "Custom Bundle URL"))
|
||||||
|
|
||||||
|
val toggleContainer = LinearLayout(context).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
gravity = android.view.Gravity.CENTER_VERTICAL
|
||||||
|
val params = LinearLayout.LayoutParams(
|
||||||
|
android.view.ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
).apply {
|
||||||
|
setMargins(0, 0, 0, LogBoxUtils.dpToPx(context, 16))
|
||||||
|
}
|
||||||
|
layoutParams = params
|
||||||
|
}
|
||||||
|
|
||||||
|
val toggleLabel = TextView(context).apply {
|
||||||
|
text = "Enable Custom URL"
|
||||||
|
textSize = 14f
|
||||||
|
setTextColor(colors.onSurface)
|
||||||
|
typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
|
||||||
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
|
0,
|
||||||
|
android.view.ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||||
|
1f
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val toggleSwitch = LogBoxComponents.createM3Switch(context, colors, isEnabled)
|
||||||
|
|
||||||
|
toggleContainer.addView(toggleLabel)
|
||||||
|
toggleContainer.addView(toggleSwitch)
|
||||||
|
container.addView(toggleContainer)
|
||||||
|
|
||||||
|
val urlInput = EditText(context).apply {
|
||||||
|
hint = "http://localhost:4040/gooncord.js"
|
||||||
|
setTextColor(colors.onSurface)
|
||||||
|
setHintTextColor(colors.onSurfaceVariant)
|
||||||
|
background = LogBoxUtils.createM3Background(context, colors.surfaceVariant, 12f)
|
||||||
|
inputType = InputType.TYPE_TEXT_VARIATION_URI
|
||||||
|
setText(currentUrl ?: "")
|
||||||
|
textSize = 14f
|
||||||
|
setPadding(
|
||||||
|
LogBoxUtils.dpToPx(context, 16),
|
||||||
|
LogBoxUtils.dpToPx(context, 12),
|
||||||
|
LogBoxUtils.dpToPx(context, 16),
|
||||||
|
LogBoxUtils.dpToPx(context, 12)
|
||||||
|
)
|
||||||
|
alpha = if (isEnabled) 1f else 0.5f
|
||||||
|
val params = LinearLayout.LayoutParams(
|
||||||
|
android.view.ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
).apply {
|
||||||
|
setMargins(0, 0, 0, LogBoxUtils.dpToPx(context, 24))
|
||||||
|
}
|
||||||
|
layoutParams = params
|
||||||
|
}
|
||||||
|
container.addView(urlInput)
|
||||||
|
|
||||||
|
toggleSwitch.setOnClickListener {
|
||||||
|
val currentState = toggleSwitch.tag as Boolean
|
||||||
|
val newState = !currentState
|
||||||
|
toggleSwitch.tag = newState
|
||||||
|
LogBoxComponents.updateSwitchAppearance(context, toggleSwitch, colors, newState)
|
||||||
|
|
||||||
|
urlInput.animate()
|
||||||
|
.alpha(if (newState) 1f else 0.5f)
|
||||||
|
.setDuration(250)
|
||||||
|
.withEndAction {
|
||||||
|
urlInput.isEnabled = newState
|
||||||
|
}
|
||||||
|
.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
val buttonContainer = LinearLayout(context).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
gravity = android.view.Gravity.END
|
||||||
|
}
|
||||||
|
|
||||||
|
val cancelButton = TextView(context).apply {
|
||||||
|
text = "Cancel"
|
||||||
|
setTextColor(colors.primary)
|
||||||
|
textSize = 14f
|
||||||
|
typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
|
||||||
|
setPadding(
|
||||||
|
LogBoxUtils.dpToPx(context, 16),
|
||||||
|
LogBoxUtils.dpToPx(context, 10),
|
||||||
|
LogBoxUtils.dpToPx(context, 16),
|
||||||
|
LogBoxUtils.dpToPx(context, 10)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val saveButton = TextView(context).apply {
|
||||||
|
text = "Save"
|
||||||
|
setTextColor(colors.primary)
|
||||||
|
textSize = 14f
|
||||||
|
typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
|
||||||
|
setPadding(
|
||||||
|
LogBoxUtils.dpToPx(context, 16),
|
||||||
|
LogBoxUtils.dpToPx(context, 10),
|
||||||
|
LogBoxUtils.dpToPx(context, 16),
|
||||||
|
LogBoxUtils.dpToPx(context, 10)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val dialog = LogBoxDialogs.createDialog(context, container)
|
||||||
|
|
||||||
|
cancelButton.setOnClickListener { dialog.dismiss() }
|
||||||
|
saveButton.setOnClickListener {
|
||||||
|
try {
|
||||||
|
val url = urlInput.text?.toString()?.trim() ?: ""
|
||||||
|
val enabled = toggleSwitch.tag as Boolean
|
||||||
|
|
||||||
|
if (enabled && url.isNotEmpty()) {
|
||||||
|
LogBoxActions.setCustomBundleURL(context, url, true)
|
||||||
|
dialog.dismiss()
|
||||||
|
} else if (!enabled) {
|
||||||
|
LogBoxActions.setCustomBundleURL(
|
||||||
|
context,
|
||||||
|
url.ifEmpty { "http://localhost:4040/shiggycord.js" },
|
||||||
|
false
|
||||||
|
)
|
||||||
|
dialog.dismiss()
|
||||||
|
} else {
|
||||||
|
Toast.makeText(context, "Please enter a valid URL", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
val err = e.message ?: "unknown error"
|
||||||
|
Toast.makeText(context, "Failed to set custom bundle: $err", Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buttonContainer.addView(cancelButton)
|
||||||
|
buttonContainer.addView(saveButton)
|
||||||
|
container.addView(buttonContainer)
|
||||||
|
|
||||||
|
LogBoxDialogs.currentMenuContainer = container
|
||||||
|
dialog.show()
|
||||||
|
LogBoxDialogs.setDialogWindowWidth(dialog, context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
package GoonXposed.xposed.modules.LogBox
|
||||||
|
|
||||||
|
import android.app.AlertDialog
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
object FlavorSelectionMenu {
|
||||||
|
|
||||||
|
fun showFlavorSelection(context: Context) {
|
||||||
|
val colors = LogBoxTheme.getM3Colors(context)
|
||||||
|
lateinit var dialog: AlertDialog
|
||||||
|
|
||||||
|
val container = LogBoxComponents.createMenuContainer(context, colors, 20)
|
||||||
|
container.addView(LogBoxComponents.createTitle(context, colors, "Select Menu Flavor", center = true))
|
||||||
|
|
||||||
|
LogBoxConstants.FLAVORS.forEach { flavor ->
|
||||||
|
container.addView(
|
||||||
|
LogBoxComponents.createM3Button(
|
||||||
|
context,
|
||||||
|
flavor.replaceFirstChar { it.uppercase() },
|
||||||
|
colors
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
dialog.dismiss()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
LogBoxUtils.saveMenuFlavor(context, flavor)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog = LogBoxDialogs.createDialog(context, container)
|
||||||
|
LogBoxDialogs.currentMenuContainer = container
|
||||||
|
dialog.show()
|
||||||
|
LogBoxDialogs.setDialogWindowWidth(dialog, context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,211 @@
|
||||||
|
package GoonXposed.xposed.modules.LogBox
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.widget.Toast
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.File
|
||||||
|
import GoonXposed.xposed.Utils.Log
|
||||||
|
import GoonXposed.xposed.Utils.Companion.reloadApp
|
||||||
|
import GoonXposed.xposed.Constants
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
|
||||||
|
object LogBoxActions {
|
||||||
|
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||||
|
|
||||||
|
fun toggleSafeMode(context: Context) {
|
||||||
|
try {
|
||||||
|
val settingsFile = File(context.filesDir, "logbox/LOGBOX_SETTINGS")
|
||||||
|
val themeFile = File(context.filesDir, "logbox/LOGBOX_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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleBundleInjection(context: Context) {
|
||||||
|
try {
|
||||||
|
val cacheDir = File(context.dataDir, Constants.CACHE_DIR)
|
||||||
|
cacheDir.mkdirs()
|
||||||
|
val bundle = File(cacheDir, Constants.MAIN_SCRIPT_FILE)
|
||||||
|
val disabled = File(cacheDir, "${Constants.MAIN_SCRIPT_FILE}.disabled")
|
||||||
|
|
||||||
|
val settingsFile = File(context.filesDir, "logbox/LOGBOX_SETTINGS")
|
||||||
|
settingsFile.parentFile?.mkdirs()
|
||||||
|
val settings = if (settingsFile.exists()) JSONObject(settingsFile.readText()) else JSONObject()
|
||||||
|
|
||||||
|
val currentlyDisabled = LogBoxUtils.isBundleInjectionDisabled(context)
|
||||||
|
val newState = !currentlyDisabled
|
||||||
|
|
||||||
|
if (bundle.exists() && disabled.exists()) {
|
||||||
|
bundle.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newState) {
|
||||||
|
// Disable injection
|
||||||
|
if (disabled.exists()) {
|
||||||
|
disabled.delete()
|
||||||
|
}
|
||||||
|
if (bundle.exists()) {
|
||||||
|
bundle.renameTo(disabled)
|
||||||
|
} else {
|
||||||
|
disabled.writeText("")
|
||||||
|
}
|
||||||
|
settings.put("bundleInjectionDisabled", true)
|
||||||
|
Toast.makeText(context, "Bundle injection disabled", Toast.LENGTH_SHORT).show()
|
||||||
|
} else {
|
||||||
|
// Enable injection
|
||||||
|
if (disabled.exists()) {
|
||||||
|
disabled.renameTo(bundle)
|
||||||
|
} else if (!bundle.exists()) {
|
||||||
|
bundle.writeText("")
|
||||||
|
}
|
||||||
|
settings.put("bundleInjectionDisabled", false)
|
||||||
|
Toast.makeText(context, "Bundle injection enabled", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsFile.writeText(settings.toString())
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("Error toggling bundle injection: ${e.message}")
|
||||||
|
showError(context, "Failed to toggle bundle injection", e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refetchBundle(context: Context) {
|
||||||
|
try {
|
||||||
|
val pyoncordDir = LogBoxUtils.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearCacheAndReset(context: Context) {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val pyoncordDir = LogBoxUtils.getPyoncordDirectory(context)
|
||||||
|
val bundleFile = File(pyoncordDir, "bundle.js")
|
||||||
|
val configFile = File(pyoncordDir, "loader.json")
|
||||||
|
|
||||||
|
bundleFile.delete()
|
||||||
|
|
||||||
|
if (configFile.exists()) {
|
||||||
|
val config = JSONObject(configFile.readText())
|
||||||
|
val customLoadUrl = config.optJSONObject("customLoadUrl") ?: JSONObject()
|
||||||
|
customLoadUrl.put("enabled", false)
|
||||||
|
customLoadUrl.put("url", "http://localhost:4040/gooncord.js")
|
||||||
|
config.put("customLoadUrl", customLoadUrl)
|
||||||
|
configFile.writeText(config.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(context, "Cache cleared successfully", Toast.LENGTH_SHORT).show()
|
||||||
|
reloadApp()
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("Error clearing cache: ${e.message}")
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(context, "Failed to clear cache", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCustomBundleURL(context: Context, url: String, enabled: Boolean) {
|
||||||
|
try {
|
||||||
|
val pyoncordDir = LogBoxUtils.getPyoncordDirectory(context)
|
||||||
|
val configFile = File(pyoncordDir, "loader.json")
|
||||||
|
|
||||||
|
val config = if (configFile.exists()) {
|
||||||
|
JSONObject(configFile.readText())
|
||||||
|
} else {
|
||||||
|
JSONObject().apply {
|
||||||
|
put("loadReactDevTools", false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val customLoadUrl = config.optJSONObject("customLoadUrl") ?: JSONObject()
|
||||||
|
customLoadUrl.put("enabled", enabled)
|
||||||
|
customLoadUrl.put("url", url)
|
||||||
|
config.put("customLoadUrl", customLoadUrl)
|
||||||
|
|
||||||
|
configFile.writeText(config.toString())
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
File(pyoncordDir, "bundle.js").delete()
|
||||||
|
Toast.makeText(context, "Custom bundle enabled", Toast.LENGTH_SHORT).show()
|
||||||
|
} else {
|
||||||
|
Toast.makeText(context, "Custom bundle disabled", 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteDevtools(context: Context) {
|
||||||
|
try {
|
||||||
|
val devtoolsFile = File(LogBoxUtils.getPyoncordDirectory(context), "preloads/reactDevtools.js")
|
||||||
|
if (devtoolsFile.exists()) {
|
||||||
|
if (devtoolsFile.delete()) {
|
||||||
|
Toast.makeText(context, "Devtools deleted", Toast.LENGTH_SHORT).show()
|
||||||
|
} else {
|
||||||
|
Toast.makeText(context, "Failed to delete Devtools", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Toast.makeText(context, "Devtools not found", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("Error deleting Devtools: ${e.message}")
|
||||||
|
showError(context, "Failed to delete Devtools", e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showError(context: Context, title: String, message: String?) {
|
||||||
|
LogBoxMenu.showErrorDialog(context, title, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,257 @@
|
||||||
|
package GoonXposed.xposed.modules.LogBox
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.res.ColorStateList
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.drawable.GradientDrawable
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import android.animation.*
|
||||||
|
import android.view.animation.DecelerateInterpolator
|
||||||
|
|
||||||
|
object LogBoxComponents {
|
||||||
|
|
||||||
|
fun createM3Button(context: Context, text: String, colors: M3Colors, onClick: () -> Unit): View {
|
||||||
|
return LinearLayout(context).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
gravity = Gravity.CENTER
|
||||||
|
background = LogBoxUtils.createM3Background(context, colors.primaryContainer, 20f)
|
||||||
|
setPadding(
|
||||||
|
LogBoxUtils.dpToPx(context, 24),
|
||||||
|
LogBoxUtils.dpToPx(context, 10),
|
||||||
|
LogBoxUtils.dpToPx(context, 24),
|
||||||
|
LogBoxUtils.dpToPx(context, 10)
|
||||||
|
)
|
||||||
|
clipToOutline = true
|
||||||
|
isClickable = true
|
||||||
|
isFocusable = true
|
||||||
|
|
||||||
|
val params = LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
LogBoxUtils.dpToPx(context, 40)
|
||||||
|
).apply {
|
||||||
|
setMargins(0, LogBoxUtils.dpToPx(context, 8), 0, 0)
|
||||||
|
}
|
||||||
|
layoutParams = params
|
||||||
|
|
||||||
|
addView(TextView(context).apply {
|
||||||
|
this.text = text
|
||||||
|
setTextColor(colors.onPrimaryContainer)
|
||||||
|
textSize = 14f
|
||||||
|
typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
|
||||||
|
gravity = Gravity.CENTER
|
||||||
|
})
|
||||||
|
|
||||||
|
val rippleDrawable = android.graphics.drawable.RippleDrawable(
|
||||||
|
ColorStateList.valueOf(Color.argb(30, 255, 255, 255)),
|
||||||
|
LogBoxUtils.createM3Background(context, colors.primaryContainer, 20f),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
background = rippleDrawable
|
||||||
|
|
||||||
|
setOnClickListener { onClick() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createTitle(context: Context, colors: M3Colors, text: String, center: Boolean = false): TextView {
|
||||||
|
return TextView(context).apply {
|
||||||
|
this.text = text
|
||||||
|
textSize = if (center) 20f else 22f
|
||||||
|
setTextColor(colors.onSurface)
|
||||||
|
typeface = android.graphics.Typeface.create("sans-serif", android.graphics.Typeface.BOLD)
|
||||||
|
setPadding(0, 0, 0, LogBoxUtils.dpToPx(context, if (center) 12 else 16))
|
||||||
|
gravity = if (center) Gravity.CENTER else Gravity.START
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createSubtitle(context: Context, colors: M3Colors, text: String): TextView {
|
||||||
|
return TextView(context).apply {
|
||||||
|
this.text = text
|
||||||
|
textSize = 14f
|
||||||
|
setTextColor(colors.onSurfaceVariant)
|
||||||
|
typeface = android.graphics.Typeface.create("sans-serif", android.graphics.Typeface.NORMAL)
|
||||||
|
setPadding(0, 0, 0, LogBoxUtils.dpToPx(context, 24))
|
||||||
|
gravity = Gravity.CENTER
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createMenuContainer(context: Context, colors: M3Colors, padding: Int = 24): LinearLayout {
|
||||||
|
return LinearLayout(context).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
setPadding(
|
||||||
|
LogBoxUtils.dpToPx(context, padding),
|
||||||
|
LogBoxUtils.dpToPx(context, padding),
|
||||||
|
LogBoxUtils.dpToPx(context, padding),
|
||||||
|
LogBoxUtils.dpToPx(context, padding)
|
||||||
|
)
|
||||||
|
background = LogBoxUtils.createM3Background(context, colors.surface, if (padding == 24) 28f else 24f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createM3Switch(context: Context, colors: M3Colors, isChecked: Boolean): View {
|
||||||
|
val switchView = LinearLayout(context).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
|
tag = isChecked
|
||||||
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
|
LogBoxUtils.dpToPx(context, 52),
|
||||||
|
LogBoxUtils.dpToPx(context, 32)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSwitchAppearance(context, switchView, colors, isChecked)
|
||||||
|
return switchView
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateSwitchAppearance(context: Context, switchView: View, colors: M3Colors, isChecked: Boolean) {
|
||||||
|
val trackColor = if (isChecked) colors.primary else colors.surfaceVariant
|
||||||
|
val thumbColor = if (isChecked) colors.onPrimary else colors.onSurfaceVariant
|
||||||
|
|
||||||
|
val currentTrackColor = if (switchView.background is GradientDrawable) {
|
||||||
|
(switchView.background as GradientDrawable).color?.defaultColor
|
||||||
|
?: (if (!isChecked) colors.primary else colors.surfaceVariant)
|
||||||
|
} else {
|
||||||
|
if (!isChecked) colors.primary else colors.surfaceVariant
|
||||||
|
}
|
||||||
|
|
||||||
|
val trackColorAnimator = ValueAnimator.ofObject(ArgbEvaluator(), currentTrackColor, trackColor)
|
||||||
|
trackColorAnimator.duration = 250
|
||||||
|
trackColorAnimator.interpolator = DecelerateInterpolator()
|
||||||
|
trackColorAnimator.addUpdateListener { animator ->
|
||||||
|
val animatedColor = animator.animatedValue as Int
|
||||||
|
val track = GradientDrawable().apply {
|
||||||
|
setColor(animatedColor)
|
||||||
|
setCornerRadius(LogBoxUtils.dpToPx(context, 16).toFloat())
|
||||||
|
}
|
||||||
|
switchView.background = track
|
||||||
|
}
|
||||||
|
|
||||||
|
if (switchView is LinearLayout) {
|
||||||
|
val thumb = if (switchView.childCount > 0) {
|
||||||
|
switchView.getChildAt(0)
|
||||||
|
} else {
|
||||||
|
View(context).apply {
|
||||||
|
val size = LogBoxUtils.dpToPx(context, 24)
|
||||||
|
layoutParams = LinearLayout.LayoutParams(size, size).apply {
|
||||||
|
marginStart = LogBoxUtils.dpToPx(context, 4)
|
||||||
|
marginEnd = LogBoxUtils.dpToPx(context, 24)
|
||||||
|
}
|
||||||
|
switchView.addView(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentParams = thumb.layoutParams as LinearLayout.LayoutParams
|
||||||
|
val targetMarginStart = if (isChecked) LogBoxUtils.dpToPx(context, 24) else LogBoxUtils.dpToPx(context, 4)
|
||||||
|
|
||||||
|
val marginAnimator = ValueAnimator.ofInt(currentParams.marginStart, targetMarginStart)
|
||||||
|
marginAnimator.duration = 250
|
||||||
|
marginAnimator.interpolator = DecelerateInterpolator()
|
||||||
|
marginAnimator.addUpdateListener { animator ->
|
||||||
|
val animatedMargin = animator.animatedValue as Int
|
||||||
|
val params = thumb.layoutParams as LinearLayout.LayoutParams
|
||||||
|
params.marginStart = animatedMargin
|
||||||
|
params.marginEnd = LogBoxUtils.dpToPx(context, 28) - animatedMargin
|
||||||
|
thumb.layoutParams = params
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentThumbColor = if (thumb.background is GradientDrawable) {
|
||||||
|
(thumb.background as GradientDrawable).color?.defaultColor
|
||||||
|
?: (if (!isChecked) colors.onPrimary else colors.onSurfaceVariant)
|
||||||
|
} else {
|
||||||
|
if (!isChecked) colors.onPrimary else colors.onSurfaceVariant
|
||||||
|
}
|
||||||
|
|
||||||
|
val thumbColorAnimator = ValueAnimator.ofObject(ArgbEvaluator(), currentThumbColor, thumbColor)
|
||||||
|
thumbColorAnimator.duration = 250
|
||||||
|
thumbColorAnimator.interpolator = DecelerateInterpolator()
|
||||||
|
thumbColorAnimator.addUpdateListener { animator ->
|
||||||
|
val animatedColor = animator.animatedValue as Int
|
||||||
|
thumb.background = GradientDrawable().apply {
|
||||||
|
setColor(animatedColor)
|
||||||
|
shape = GradientDrawable.OVAL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val animatorSet = AnimatorSet()
|
||||||
|
animatorSet.playTogether(trackColorAnimator, marginAnimator, thumbColorAnimator)
|
||||||
|
animatorSet.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createAppearanceSelector(context: Context, colors: M3Colors): View {
|
||||||
|
val currentMode = LogBoxUtils.getAppearanceMode(context)
|
||||||
|
val options = listOf("System", "Light", "Dark")
|
||||||
|
val keys = listOf("system", "light", "dark")
|
||||||
|
|
||||||
|
val wrapper = LinearLayout(context).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
val params = LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
)
|
||||||
|
layoutParams = params
|
||||||
|
setPadding(0, 0, 0, LogBoxUtils.dpToPx(context, 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
val label = TextView(context).apply {
|
||||||
|
text = "Appearance"
|
||||||
|
textSize = 14f
|
||||||
|
setTextColor(colors.onSurface)
|
||||||
|
setPadding(0, 0, 0, LogBoxUtils.dpToPx(context, 8))
|
||||||
|
typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
|
||||||
|
}
|
||||||
|
wrapper.addView(label)
|
||||||
|
|
||||||
|
val row = LinearLayout(context).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
val params = LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
LogBoxUtils.dpToPx(context, 40)
|
||||||
|
)
|
||||||
|
layoutParams = params
|
||||||
|
}
|
||||||
|
|
||||||
|
options.forEachIndexed { idx, title ->
|
||||||
|
val key = keys[idx]
|
||||||
|
val seg = TextView(context).apply {
|
||||||
|
text = title
|
||||||
|
gravity = Gravity.CENTER
|
||||||
|
textSize = 14f
|
||||||
|
typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
|
||||||
|
val segParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f)
|
||||||
|
if (idx > 0) segParams.setMargins(LogBoxUtils.dpToPx(context, 8), 0, 0, 0)
|
||||||
|
layoutParams = segParams
|
||||||
|
|
||||||
|
val isSelected = currentMode == key
|
||||||
|
background = LogBoxUtils.createM3Background(
|
||||||
|
context,
|
||||||
|
if (isSelected) colors.primaryContainer else colors.surfaceVariant,
|
||||||
|
12f
|
||||||
|
)
|
||||||
|
setTextColor(if (isSelected) colors.onPrimaryContainer else colors.onSurface)
|
||||||
|
setPadding(0, 0, 0, 0)
|
||||||
|
setOnClickListener {
|
||||||
|
LogBoxUtils.setAppearanceMode(context, key)
|
||||||
|
|
||||||
|
for (i in 0 until row.childCount) {
|
||||||
|
val child = row.getChildAt(i) as TextView
|
||||||
|
val sel = keys[i] == key
|
||||||
|
child.background = LogBoxUtils.createM3Background(
|
||||||
|
context,
|
||||||
|
if (sel) colors.primaryContainer else colors.surfaceVariant,
|
||||||
|
12f
|
||||||
|
)
|
||||||
|
child.setTextColor(if (sel) colors.onPrimaryContainer else colors.onSurface)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row.addView(seg)
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapper.addView(row)
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
package GoonXposed.xposed.modules.LogBox
|
||||||
|
|
||||||
|
import android.graphics.Color
|
||||||
|
|
||||||
|
data class M3Colors(
|
||||||
|
val surface: Int,
|
||||||
|
val surfaceVariant: Int,
|
||||||
|
val onSurface: Int,
|
||||||
|
val onSurfaceVariant: Int,
|
||||||
|
val primary: Int,
|
||||||
|
val onPrimary: Int,
|
||||||
|
val primaryContainer: Int,
|
||||||
|
val onPrimaryContainer: Int,
|
||||||
|
val secondaryContainer: Int,
|
||||||
|
val onSecondaryContainer: Int,
|
||||||
|
val error: Int,
|
||||||
|
val onError: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
object LogBoxConstants {
|
||||||
|
const val TAG = "LogBoxModule"
|
||||||
|
|
||||||
|
val FLAVOR_COLORS = mapOf(
|
||||||
|
"blue" to Pair("#0D47A1", "#82B1FF"),
|
||||||
|
"green" to Pair("#1B5E20", "#A5D6A7"),
|
||||||
|
"mocha" to Pair("#3E2723", "#BCAAA4"),
|
||||||
|
"vanilla" to Pair("#F9A825", "#FFF59D"),
|
||||||
|
"purple" to Pair("#6A1B9A", "#E1BEE7"),
|
||||||
|
"amber" to Pair("#FF6F00", "#FFE0B2"),
|
||||||
|
"teal" to Pair("#004D40", "#80CBC4")
|
||||||
|
)
|
||||||
|
|
||||||
|
val APPEARANCE_MODES = listOf("system", "light", "dark")
|
||||||
|
val FLAVORS = listOf("blue", "green", "mocha", "vanilla", "purple", "amber", "teal")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
package GoonXposed.xposed.modules.LogBox
|
||||||
|
|
||||||
|
import android.app.AlertDialog
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import GoonXposed.xposed.Utils.Log
|
||||||
|
|
||||||
|
object LogBoxDialogs {
|
||||||
|
|
||||||
|
var currentMenuContainer: LinearLayout? = null
|
||||||
|
|
||||||
|
fun createDialog(context: Context, container: LinearLayout): AlertDialog {
|
||||||
|
val dialog = AlertDialog.Builder(context)
|
||||||
|
.setView(container)
|
||||||
|
.create()
|
||||||
|
|
||||||
|
dialog.window?.setBackgroundDrawable(
|
||||||
|
LogBoxUtils.createM3Background(context, Color.TRANSPARENT, 28f)
|
||||||
|
)
|
||||||
|
|
||||||
|
constrainContainerWidth(context, container)
|
||||||
|
return dialog
|
||||||
|
}
|
||||||
|
|
||||||
|
fun constrainContainerWidth(context: Context, container: LinearLayout) {
|
||||||
|
try {
|
||||||
|
val maxW = (context.resources.displayMetrics.widthPixels * 0.9).toInt()
|
||||||
|
val lp = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||||
|
container.layoutParams = lp
|
||||||
|
try {
|
||||||
|
container.minimumWidth = maxW
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDialogWindowWidth(dialog: AlertDialog, context: Context) {
|
||||||
|
try {
|
||||||
|
val screenW = context.resources.displayMetrics.widthPixels
|
||||||
|
val maxByPercent = (screenW * 0.9).toInt()
|
||||||
|
val maxByDp = LogBoxUtils.dpToPx(context, 420)
|
||||||
|
val targetW = if (maxByPercent < maxByDp) maxByPercent else maxByDp
|
||||||
|
|
||||||
|
dialog.window?.setLayout(targetW, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||||
|
|
||||||
|
try {
|
||||||
|
currentMenuContainer?.let { container ->
|
||||||
|
val lp = container.layoutParams ?: LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
)
|
||||||
|
lp.width = ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
container.layoutParams = lp
|
||||||
|
try {
|
||||||
|
container.minimumWidth = targetW
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
container.post {
|
||||||
|
try {
|
||||||
|
container.requestLayout()
|
||||||
|
container.invalidate()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,795 @@
|
||||||
|
package GoonXposed.xposed.modules.LogBox
|
||||||
|
|
||||||
|
import android.app.AlertDialog
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.*
|
||||||
|
import android.animation.*
|
||||||
|
import android.graphics.drawable.GradientDrawable
|
||||||
|
import android.text.InputType
|
||||||
|
import android.content.res.ColorStateList
|
||||||
|
import android.view.animation.DecelerateInterpolator
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.File
|
||||||
|
import GoonXposed.xposed.Utils.Log
|
||||||
|
import GoonXposed.xposed.Utils
|
||||||
|
|
||||||
|
object LogBoxMenu {
|
||||||
|
|
||||||
|
private var currentMenuContainer: LinearLayout? = null
|
||||||
|
|
||||||
|
fun showRecoveryMenu(context: Context) {
|
||||||
|
Log.e("showRecoveryMenu called with context: $context")
|
||||||
|
try {
|
||||||
|
val colors = LogBoxTheme.getM3Colors(context)
|
||||||
|
lateinit var dialog: AlertDialog
|
||||||
|
|
||||||
|
val container = createMenuContainer(context, colors)
|
||||||
|
|
||||||
|
// Title
|
||||||
|
container.addView(createTitle(context, colors, "GoonXposed Recovery", center = true))
|
||||||
|
|
||||||
|
// Subtitle
|
||||||
|
container.addView(createSubtitle(context, colors, "Select an option to continue"))
|
||||||
|
|
||||||
|
// Safe Mode Button
|
||||||
|
val safeModeText = if (LogBoxUtils.isSafeModeEnabled(context)) "Disable Safe Mode" else "Enable Safe Mode"
|
||||||
|
container.addView(createM3Button(context, safeModeText, colors) {
|
||||||
|
dialog.dismiss()
|
||||||
|
handleMenuSelection(context, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Load Custom Bundle Button
|
||||||
|
container.addView(createM3Button(context, "Load Custom Bundle", colors) {
|
||||||
|
dialog.dismiss()
|
||||||
|
handleMenuSelection(context, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reload App Button
|
||||||
|
container.addView(createM3Button(context, "Reload App", colors) {
|
||||||
|
dialog.dismiss()
|
||||||
|
handleMenuSelection(context, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Options Button
|
||||||
|
val optionsBtn = createM3Button(context, "Options", colors) {
|
||||||
|
dialog.dismiss()
|
||||||
|
handleMenuSelection(context, 5)
|
||||||
|
}
|
||||||
|
(optionsBtn.layoutParams as? LinearLayout.LayoutParams)?.let { lp ->
|
||||||
|
lp.setMargins(lp.leftMargin, LogBoxUtils.dpToPx(context, 16), lp.rightMargin, lp.bottomMargin)
|
||||||
|
optionsBtn.layoutParams = lp
|
||||||
|
}
|
||||||
|
container.addView(optionsBtn)
|
||||||
|
|
||||||
|
dialog = createDialog(context, container)
|
||||||
|
currentMenuContainer = container
|
||||||
|
dialog.show()
|
||||||
|
setDialogWindowWidth(dialog, context)
|
||||||
|
Log.e("Recovery menu shown successfully")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("Error showing recovery menu: ${e.message}", e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showOptionsMenu(context: Context) {
|
||||||
|
val colors = LogBoxTheme.getM3Colors(context)
|
||||||
|
lateinit var dialog: AlertDialog
|
||||||
|
|
||||||
|
val container = createMenuContainer(context, colors, 20)
|
||||||
|
|
||||||
|
container.addView(createTitle(context, colors, "Options", center = true))
|
||||||
|
|
||||||
|
// Themes button -> opens Themes submenu
|
||||||
|
container.addView(createM3Button(context, "Themes", colors) {
|
||||||
|
dialog.dismiss()
|
||||||
|
showThemesMenu(context)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Disable/Enable bundle injection
|
||||||
|
val injectionDisabled = LogBoxUtils.isBundleInjectionDisabled(context)
|
||||||
|
val injectionText = if (injectionDisabled) "Enable Bundle Injection" else "Disable Bundle Injection"
|
||||||
|
container.addView(createM3Button(context, injectionText, colors) {
|
||||||
|
dialog.dismiss()
|
||||||
|
LogBoxActions.toggleBundleInjection(context)
|
||||||
|
Utils.reloadApp()
|
||||||
|
})
|
||||||
|
|
||||||
|
container.addView(createM3Button(context, "Refetch Bundle", colors) {
|
||||||
|
dialog.dismiss()
|
||||||
|
showConfirmAction(context, "Refetch Bundle", "This will download the latest bundle from Github.") {
|
||||||
|
LogBoxActions.refetchBundle(context)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
container.addView(createM3Button(context, "Clear Cache & Reset", colors) {
|
||||||
|
dialog.dismiss()
|
||||||
|
showConfirmAction(
|
||||||
|
context,
|
||||||
|
"Clear Cache & Reset",
|
||||||
|
"This will clear all cached bundles and reset to default settings."
|
||||||
|
) {
|
||||||
|
LogBoxActions.clearCacheAndReset(context)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
container.addView(createM3Button(context, "Delete Devtools", colors) {
|
||||||
|
dialog.dismiss()
|
||||||
|
showConfirmAction(
|
||||||
|
context,
|
||||||
|
"Delete Devtools",
|
||||||
|
"This will delete the reactDevtools.js preload file if it exists."
|
||||||
|
) {
|
||||||
|
LogBoxActions.deleteDevtools(context)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
container.addView(createM3Button(context, "Back", colors) {
|
||||||
|
dialog.dismiss()
|
||||||
|
showRecoveryMenu(context)
|
||||||
|
})
|
||||||
|
|
||||||
|
dialog = createDialog(context, container)
|
||||||
|
currentMenuContainer = container
|
||||||
|
dialog.show()
|
||||||
|
setDialogWindowWidth(dialog, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showThemesMenu(context: Context) {
|
||||||
|
val colors = LogBoxTheme.getM3Colors(context)
|
||||||
|
lateinit var dialog: AlertDialog
|
||||||
|
|
||||||
|
val container = createMenuContainer(context, colors, 20)
|
||||||
|
|
||||||
|
container.addView(createTitle(context, colors, "Themes", center = true))
|
||||||
|
|
||||||
|
// Segmented appearance selector
|
||||||
|
container.addView(createAppearanceSelector(context, colors))
|
||||||
|
|
||||||
|
// Flavor selection
|
||||||
|
container.addView(createM3Button(context, "Menu Color Flavor", colors) {
|
||||||
|
dialog.dismiss()
|
||||||
|
showFlavorSelection(context)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Back button (return to Options)
|
||||||
|
container.addView(createM3Button(context, "Back", colors) {
|
||||||
|
dialog.dismiss()
|
||||||
|
showOptionsMenu(context)
|
||||||
|
})
|
||||||
|
|
||||||
|
dialog = createDialog(context, container)
|
||||||
|
currentMenuContainer = container
|
||||||
|
dialog.show()
|
||||||
|
setDialogWindowWidth(dialog, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showFlavorSelection(context: Context) {
|
||||||
|
val colors = LogBoxTheme.getM3Colors(context)
|
||||||
|
lateinit var dialog: AlertDialog
|
||||||
|
|
||||||
|
val container = createMenuContainer(context, colors, 20)
|
||||||
|
|
||||||
|
container.addView(createTitle(context, colors, "Select Menu Flavor", center = true))
|
||||||
|
|
||||||
|
LogBoxConstants.FLAVORS.forEach { flavor ->
|
||||||
|
container.addView(createM3Button(context, flavor.replaceFirstChar { it.uppercase() }, colors) {
|
||||||
|
try {
|
||||||
|
dialog.dismiss()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
LogBoxUtils.saveMenuFlavor(context, flavor)
|
||||||
|
showThemesMenu(context)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog = createDialog(context, container)
|
||||||
|
currentMenuContainer = container
|
||||||
|
dialog.show()
|
||||||
|
setDialogWindowWidth(dialog, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showConfirmAction(context: Context, title: String, message: String, action: () -> Unit) {
|
||||||
|
val colors = LogBoxTheme.getM3Colors(context)
|
||||||
|
|
||||||
|
val container = createMenuContainer(context, colors)
|
||||||
|
|
||||||
|
container.addView(createTitle(context, colors, title))
|
||||||
|
|
||||||
|
val messageView = TextView(context).apply {
|
||||||
|
text = message
|
||||||
|
textSize = 14f
|
||||||
|
setTextColor(colors.onSurfaceVariant)
|
||||||
|
setPadding(0, 0, 0, LogBoxUtils.dpToPx(context, 24))
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
|
||||||
|
lineHeight = LogBoxUtils.dpToPx(context, 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
container.addView(messageView)
|
||||||
|
|
||||||
|
val buttonContainer = LinearLayout(context).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
gravity = Gravity.END
|
||||||
|
}
|
||||||
|
|
||||||
|
val cancelButton = TextView(context).apply {
|
||||||
|
text = "Cancel"
|
||||||
|
setTextColor(colors.primary)
|
||||||
|
textSize = 14f
|
||||||
|
typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
|
||||||
|
setPadding(
|
||||||
|
LogBoxUtils.dpToPx(context, 16),
|
||||||
|
LogBoxUtils.dpToPx(context, 10),
|
||||||
|
LogBoxUtils.dpToPx(context, 16),
|
||||||
|
LogBoxUtils.dpToPx(context, 10)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val confirmButton = TextView(context).apply {
|
||||||
|
text = "Confirm"
|
||||||
|
setTextColor(colors.primary)
|
||||||
|
textSize = 14f
|
||||||
|
typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
|
||||||
|
setPadding(
|
||||||
|
LogBoxUtils.dpToPx(context, 16),
|
||||||
|
LogBoxUtils.dpToPx(context, 10),
|
||||||
|
LogBoxUtils.dpToPx(context, 16),
|
||||||
|
LogBoxUtils.dpToPx(context, 10)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val dialog = createDialog(context, container)
|
||||||
|
|
||||||
|
cancelButton.setOnClickListener { dialog.dismiss() }
|
||||||
|
confirmButton.setOnClickListener {
|
||||||
|
dialog.dismiss()
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
|
||||||
|
buttonContainer.addView(cancelButton)
|
||||||
|
buttonContainer.addView(confirmButton)
|
||||||
|
container.addView(buttonContainer)
|
||||||
|
|
||||||
|
currentMenuContainer = container
|
||||||
|
dialog.show()
|
||||||
|
setDialogWindowWidth(dialog, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showCustomBundleDialog(context: Context) {
|
||||||
|
val colors = LogBoxTheme.getM3Colors(context)
|
||||||
|
val filesDir = LogBoxUtils.getPyoncordDirectory(context)
|
||||||
|
val configFile = File(filesDir, "loader.json")
|
||||||
|
var currentUrl: String? = null
|
||||||
|
var isEnabled = false
|
||||||
|
|
||||||
|
if (configFile.exists()) {
|
||||||
|
try {
|
||||||
|
val json = JSONObject(configFile.readText())
|
||||||
|
val customLoadUrl = json.optJSONObject("customLoadUrl")
|
||||||
|
if (customLoadUrl != null) {
|
||||||
|
isEnabled = customLoadUrl.optBoolean("enabled", false)
|
||||||
|
currentUrl = customLoadUrl.optString("url", "")
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val container = createMenuContainer(context, colors)
|
||||||
|
|
||||||
|
container.addView(createTitle(context, colors, "Custom Bundle URL"))
|
||||||
|
|
||||||
|
// Toggle switch container
|
||||||
|
val toggleContainer = LinearLayout(context).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
|
val params = LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
).apply {
|
||||||
|
setMargins(0, 0, 0, LogBoxUtils.dpToPx(context, 16))
|
||||||
|
}
|
||||||
|
layoutParams = params
|
||||||
|
}
|
||||||
|
|
||||||
|
val toggleLabel = TextView(context).apply {
|
||||||
|
text = "Enable Custom URL"
|
||||||
|
textSize = 14f
|
||||||
|
setTextColor(colors.onSurface)
|
||||||
|
typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
|
||||||
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
|
0,
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||||
|
1f
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val toggleSwitch = createM3Switch(context, colors, isEnabled)
|
||||||
|
|
||||||
|
toggleContainer.addView(toggleLabel)
|
||||||
|
toggleContainer.addView(toggleSwitch)
|
||||||
|
container.addView(toggleContainer)
|
||||||
|
|
||||||
|
val urlInput = EditText(context).apply {
|
||||||
|
hint = "http://localhost:4040/shiggycord.js"
|
||||||
|
setTextColor(colors.onSurface)
|
||||||
|
setHintTextColor(colors.onSurfaceVariant)
|
||||||
|
background = LogBoxUtils.createM3Background(context, colors.surfaceVariant, 12f)
|
||||||
|
inputType = InputType.TYPE_TEXT_VARIATION_URI
|
||||||
|
setText(currentUrl ?: "")
|
||||||
|
textSize = 14f
|
||||||
|
setPadding(
|
||||||
|
LogBoxUtils.dpToPx(context, 16),
|
||||||
|
LogBoxUtils.dpToPx(context, 12),
|
||||||
|
LogBoxUtils.dpToPx(context, 16),
|
||||||
|
LogBoxUtils.dpToPx(context, 12)
|
||||||
|
)
|
||||||
|
alpha = if (isEnabled) 1f else 0.5f
|
||||||
|
val params = LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
).apply {
|
||||||
|
setMargins(0, 0, 0, LogBoxUtils.dpToPx(context, 24))
|
||||||
|
}
|
||||||
|
layoutParams = params
|
||||||
|
}
|
||||||
|
container.addView(urlInput)
|
||||||
|
|
||||||
|
toggleSwitch.setOnClickListener {
|
||||||
|
val currentState = toggleSwitch.tag as Boolean
|
||||||
|
val newState = !currentState
|
||||||
|
toggleSwitch.tag = newState
|
||||||
|
updateSwitchAppearance(context, toggleSwitch, colors, newState)
|
||||||
|
|
||||||
|
urlInput.animate()
|
||||||
|
.alpha(if (newState) 1f else 0.5f)
|
||||||
|
.setDuration(250)
|
||||||
|
.withEndAction {
|
||||||
|
urlInput.isEnabled = newState
|
||||||
|
}
|
||||||
|
.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
val buttonContainer = LinearLayout(context).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
gravity = Gravity.END
|
||||||
|
}
|
||||||
|
|
||||||
|
val cancelButton = TextView(context).apply {
|
||||||
|
text = "Cancel"
|
||||||
|
setTextColor(colors.primary)
|
||||||
|
textSize = 14f
|
||||||
|
typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
|
||||||
|
setPadding(
|
||||||
|
LogBoxUtils.dpToPx(context, 16),
|
||||||
|
LogBoxUtils.dpToPx(context, 10),
|
||||||
|
LogBoxUtils.dpToPx(context, 16),
|
||||||
|
LogBoxUtils.dpToPx(context, 10)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val saveButton = TextView(context).apply {
|
||||||
|
text = "Save"
|
||||||
|
setTextColor(colors.primary)
|
||||||
|
textSize = 14f
|
||||||
|
typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
|
||||||
|
setPadding(
|
||||||
|
LogBoxUtils.dpToPx(context, 16),
|
||||||
|
LogBoxUtils.dpToPx(context, 10),
|
||||||
|
LogBoxUtils.dpToPx(context, 16),
|
||||||
|
LogBoxUtils.dpToPx(context, 10)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val dialog = createDialog(context, container)
|
||||||
|
|
||||||
|
cancelButton.setOnClickListener { dialog.dismiss() }
|
||||||
|
saveButton.setOnClickListener {
|
||||||
|
try {
|
||||||
|
val url = urlInput.text?.toString()?.trim() ?: ""
|
||||||
|
val enabled = toggleSwitch.tag as Boolean
|
||||||
|
|
||||||
|
if (enabled && url.isNotEmpty()) {
|
||||||
|
LogBoxActions.setCustomBundleURL(context, url, true)
|
||||||
|
dialog.dismiss()
|
||||||
|
} else if (!enabled) {
|
||||||
|
LogBoxActions.setCustomBundleURL(
|
||||||
|
context,
|
||||||
|
url.ifEmpty { "http://localhost:4040/shiggycord.js" },
|
||||||
|
false
|
||||||
|
)
|
||||||
|
dialog.dismiss()
|
||||||
|
} else {
|
||||||
|
Toast.makeText(context, "Please enter a valid URL", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
val err = e.message ?: "unknown error"
|
||||||
|
Toast.makeText(context, "Failed to set custom bundle: $err", Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buttonContainer.addView(cancelButton)
|
||||||
|
buttonContainer.addView(saveButton)
|
||||||
|
container.addView(buttonContainer)
|
||||||
|
|
||||||
|
currentMenuContainer = container
|
||||||
|
dialog.show()
|
||||||
|
setDialogWindowWidth(dialog, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showErrorDialog(context: Context, title: String, message: String?) {
|
||||||
|
val colors = LogBoxTheme.getM3Colors(context)
|
||||||
|
|
||||||
|
val container = createMenuContainer(context, colors)
|
||||||
|
|
||||||
|
val titleView = TextView(context).apply {
|
||||||
|
text = title
|
||||||
|
textSize = 20f
|
||||||
|
setTextColor(colors.error)
|
||||||
|
typeface = android.graphics.Typeface.create("sans-serif", android.graphics.Typeface.BOLD)
|
||||||
|
setPadding(0, 0, 0, LogBoxUtils.dpToPx(context, 16))
|
||||||
|
}
|
||||||
|
container.addView(titleView)
|
||||||
|
|
||||||
|
val messageView = TextView(context).apply {
|
||||||
|
text = message ?: "An unknown error occurred"
|
||||||
|
textSize = 14f
|
||||||
|
setTextColor(colors.onSurfaceVariant)
|
||||||
|
setPadding(0, 0, 0, LogBoxUtils.dpToPx(context, 24))
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
|
||||||
|
lineHeight = LogBoxUtils.dpToPx(context, 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
container.addView(messageView)
|
||||||
|
|
||||||
|
val okButton = TextView(context).apply {
|
||||||
|
text = "OK"
|
||||||
|
setTextColor(colors.primary)
|
||||||
|
textSize = 14f
|
||||||
|
typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
|
||||||
|
gravity = Gravity.END
|
||||||
|
setPadding(
|
||||||
|
LogBoxUtils.dpToPx(context, 16),
|
||||||
|
LogBoxUtils.dpToPx(context, 10),
|
||||||
|
LogBoxUtils.dpToPx(context, 16),
|
||||||
|
LogBoxUtils.dpToPx(context, 10)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val dialog = createDialog(context, container)
|
||||||
|
|
||||||
|
okButton.setOnClickListener { dialog.dismiss() }
|
||||||
|
container.addView(okButton)
|
||||||
|
|
||||||
|
currentMenuContainer = container
|
||||||
|
dialog.show()
|
||||||
|
setDialogWindowWidth(dialog, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
private fun createMenuContainer(context: Context, colors: M3Colors, padding: Int = 24): LinearLayout {
|
||||||
|
return LinearLayout(context).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
setPadding(
|
||||||
|
LogBoxUtils.dpToPx(context, padding),
|
||||||
|
LogBoxUtils.dpToPx(context, padding),
|
||||||
|
LogBoxUtils.dpToPx(context, padding),
|
||||||
|
LogBoxUtils.dpToPx(context, padding)
|
||||||
|
)
|
||||||
|
background = LogBoxUtils.createM3Background(context, colors.surface, if (padding == 24) 28f else 24f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createTitle(context: Context, colors: M3Colors, text: String, center: Boolean = false): TextView {
|
||||||
|
return TextView(context).apply {
|
||||||
|
this.text = text
|
||||||
|
textSize = if (center) 20f else 22f
|
||||||
|
setTextColor(colors.onSurface)
|
||||||
|
typeface = android.graphics.Typeface.create("sans-serif", android.graphics.Typeface.BOLD)
|
||||||
|
setPadding(0, 0, 0, LogBoxUtils.dpToPx(context, if (center) 12 else 16))
|
||||||
|
gravity = if (center) Gravity.CENTER else Gravity.START
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createSubtitle(context: Context, colors: M3Colors, text: String): TextView {
|
||||||
|
return TextView(context).apply {
|
||||||
|
this.text = text
|
||||||
|
textSize = 14f
|
||||||
|
setTextColor(colors.onSurfaceVariant)
|
||||||
|
typeface = android.graphics.Typeface.create("sans-serif", android.graphics.Typeface.NORMAL)
|
||||||
|
setPadding(0, 0, 0, LogBoxUtils.dpToPx(context, 24))
|
||||||
|
gravity = Gravity.CENTER
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createM3Button(context: Context, text: String, colors: M3Colors, onClick: () -> Unit): View {
|
||||||
|
return LinearLayout(context).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
gravity = Gravity.CENTER
|
||||||
|
background = LogBoxUtils.createM3Background(context, colors.primaryContainer, 20f)
|
||||||
|
setPadding(
|
||||||
|
LogBoxUtils.dpToPx(context, 24),
|
||||||
|
LogBoxUtils.dpToPx(context, 10),
|
||||||
|
LogBoxUtils.dpToPx(context, 24),
|
||||||
|
LogBoxUtils.dpToPx(context, 10)
|
||||||
|
)
|
||||||
|
clipToOutline = true
|
||||||
|
isClickable = true
|
||||||
|
isFocusable = true
|
||||||
|
|
||||||
|
val params = LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
LogBoxUtils.dpToPx(context, 40)
|
||||||
|
).apply {
|
||||||
|
setMargins(0, LogBoxUtils.dpToPx(context, 8), 0, 0)
|
||||||
|
}
|
||||||
|
layoutParams = params
|
||||||
|
|
||||||
|
addView(TextView(context).apply {
|
||||||
|
this.text = text
|
||||||
|
setTextColor(colors.onPrimaryContainer)
|
||||||
|
textSize = 14f
|
||||||
|
typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
|
||||||
|
gravity = Gravity.CENTER
|
||||||
|
})
|
||||||
|
|
||||||
|
val rippleDrawable = android.graphics.drawable.RippleDrawable(
|
||||||
|
ColorStateList.valueOf(Color.argb(30, 255, 255, 255)),
|
||||||
|
LogBoxUtils.createM3Background(context, colors.primaryContainer, 20f),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
background = rippleDrawable
|
||||||
|
|
||||||
|
setOnClickListener { onClick() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createAppearanceSelector(context: Context, colors: M3Colors): View {
|
||||||
|
val currentMode = LogBoxUtils.getAppearanceMode(context)
|
||||||
|
|
||||||
|
val options = listOf("System", "Light", "Dark")
|
||||||
|
val keys = listOf("system", "light", "dark")
|
||||||
|
|
||||||
|
val wrapper = LinearLayout(context).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
val params = LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
)
|
||||||
|
layoutParams = params
|
||||||
|
setPadding(0, 0, 0, LogBoxUtils.dpToPx(context, 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
val label = TextView(context).apply {
|
||||||
|
text = "Appearance"
|
||||||
|
textSize = 14f
|
||||||
|
setTextColor(colors.onSurface)
|
||||||
|
setPadding(0, 0, 0, LogBoxUtils.dpToPx(context, 8))
|
||||||
|
typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
|
||||||
|
}
|
||||||
|
wrapper.addView(label)
|
||||||
|
|
||||||
|
val row = LinearLayout(context).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
val params = LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
LogBoxUtils.dpToPx(context, 40)
|
||||||
|
)
|
||||||
|
layoutParams = params
|
||||||
|
}
|
||||||
|
|
||||||
|
options.forEachIndexed { idx, title ->
|
||||||
|
val key = keys[idx]
|
||||||
|
val seg = TextView(context).apply {
|
||||||
|
text = title
|
||||||
|
gravity = Gravity.CENTER
|
||||||
|
textSize = 14f
|
||||||
|
typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
|
||||||
|
val segParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f)
|
||||||
|
if (idx > 0) segParams.setMargins(LogBoxUtils.dpToPx(context, 8), 0, 0, 0)
|
||||||
|
layoutParams = segParams
|
||||||
|
|
||||||
|
val isSelected = currentMode == key
|
||||||
|
background = LogBoxUtils.createM3Background(
|
||||||
|
context,
|
||||||
|
if (isSelected) colors.primaryContainer else colors.surfaceVariant,
|
||||||
|
12f
|
||||||
|
)
|
||||||
|
setTextColor(if (isSelected) colors.onPrimaryContainer else colors.onSurface)
|
||||||
|
setPadding(0, 0, 0, 0)
|
||||||
|
setOnClickListener {
|
||||||
|
LogBoxUtils.setAppearanceMode(context, key)
|
||||||
|
|
||||||
|
for (i in 0 until row.childCount) {
|
||||||
|
val child = row.getChildAt(i) as TextView
|
||||||
|
val sel = keys[i] == key
|
||||||
|
child.background = LogBoxUtils.createM3Background(
|
||||||
|
context,
|
||||||
|
if (sel) colors.primaryContainer else colors.surfaceVariant,
|
||||||
|
12f
|
||||||
|
)
|
||||||
|
child.setTextColor(if (sel) colors.onPrimaryContainer else colors.onSurface)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row.addView(seg)
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapper.addView(row)
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createM3Switch(context: Context, colors: M3Colors, isChecked: Boolean): View {
|
||||||
|
val switchView = LinearLayout(context).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
|
tag = isChecked
|
||||||
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
|
LogBoxUtils.dpToPx(context, 52),
|
||||||
|
LogBoxUtils.dpToPx(context, 32)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSwitchAppearance(context, switchView, colors, isChecked)
|
||||||
|
return switchView
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateSwitchAppearance(context: Context, switchView: View, colors: M3Colors, isChecked: Boolean) {
|
||||||
|
val trackColor = if (isChecked) colors.primary else colors.surfaceVariant
|
||||||
|
val thumbColor = if (isChecked) colors.onPrimary else colors.onSurfaceVariant
|
||||||
|
|
||||||
|
val currentTrackColor = if (switchView.background is GradientDrawable) {
|
||||||
|
(switchView.background as GradientDrawable).color?.defaultColor
|
||||||
|
?: (if (!isChecked) colors.primary else colors.surfaceVariant)
|
||||||
|
} else {
|
||||||
|
if (!isChecked) colors.primary else colors.surfaceVariant
|
||||||
|
}
|
||||||
|
|
||||||
|
val trackColorAnimator = ValueAnimator.ofObject(ArgbEvaluator(), currentTrackColor, trackColor)
|
||||||
|
trackColorAnimator.duration = 250
|
||||||
|
trackColorAnimator.interpolator = DecelerateInterpolator()
|
||||||
|
trackColorAnimator.addUpdateListener { animator ->
|
||||||
|
val animatedColor = animator.animatedValue as Int
|
||||||
|
val track = GradientDrawable().apply {
|
||||||
|
setColor(animatedColor)
|
||||||
|
setCornerRadius(LogBoxUtils.dpToPx(context, 16).toFloat())
|
||||||
|
}
|
||||||
|
switchView.background = track
|
||||||
|
}
|
||||||
|
|
||||||
|
if (switchView is LinearLayout) {
|
||||||
|
val thumb = if (switchView.childCount > 0) {
|
||||||
|
switchView.getChildAt(0)
|
||||||
|
} else {
|
||||||
|
View(context).apply {
|
||||||
|
val size = LogBoxUtils.dpToPx(context, 24)
|
||||||
|
layoutParams = LinearLayout.LayoutParams(size, size).apply {
|
||||||
|
marginStart = LogBoxUtils.dpToPx(context, 4)
|
||||||
|
marginEnd = LogBoxUtils.dpToPx(context, 24)
|
||||||
|
}
|
||||||
|
switchView.addView(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentParams = thumb.layoutParams as LinearLayout.LayoutParams
|
||||||
|
val targetMarginStart = if (isChecked) LogBoxUtils.dpToPx(context, 24) else LogBoxUtils.dpToPx(context, 4)
|
||||||
|
|
||||||
|
val marginAnimator = ValueAnimator.ofInt(currentParams.marginStart, targetMarginStart)
|
||||||
|
marginAnimator.duration = 250
|
||||||
|
marginAnimator.interpolator = DecelerateInterpolator()
|
||||||
|
marginAnimator.addUpdateListener { animator ->
|
||||||
|
val animatedMargin = animator.animatedValue as Int
|
||||||
|
val params = thumb.layoutParams as LinearLayout.LayoutParams
|
||||||
|
params.marginStart = animatedMargin
|
||||||
|
params.marginEnd = LogBoxUtils.dpToPx(context, 28) - animatedMargin
|
||||||
|
thumb.layoutParams = params
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentThumbColor = if (thumb.background is GradientDrawable) {
|
||||||
|
(thumb.background as GradientDrawable).color?.defaultColor
|
||||||
|
?: (if (!isChecked) colors.onPrimary else colors.onSurfaceVariant)
|
||||||
|
} else {
|
||||||
|
if (!isChecked) colors.onPrimary else colors.onSurfaceVariant
|
||||||
|
}
|
||||||
|
|
||||||
|
val thumbColorAnimator = ValueAnimator.ofObject(ArgbEvaluator(), currentThumbColor, thumbColor)
|
||||||
|
thumbColorAnimator.duration = 250
|
||||||
|
thumbColorAnimator.interpolator = DecelerateInterpolator()
|
||||||
|
thumbColorAnimator.addUpdateListener { animator ->
|
||||||
|
val animatedColor = animator.animatedValue as Int
|
||||||
|
thumb.background = GradientDrawable().apply {
|
||||||
|
setColor(animatedColor)
|
||||||
|
shape = GradientDrawable.OVAL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val animatorSet = AnimatorSet()
|
||||||
|
animatorSet.playTogether(trackColorAnimator, marginAnimator, thumbColorAnimator)
|
||||||
|
animatorSet.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createDialog(context: Context, container: LinearLayout): AlertDialog {
|
||||||
|
val dialog = AlertDialog.Builder(context)
|
||||||
|
.setView(container)
|
||||||
|
.create()
|
||||||
|
|
||||||
|
dialog.window?.setBackgroundDrawable(
|
||||||
|
LogBoxUtils.createM3Background(context, Color.TRANSPARENT, 28f)
|
||||||
|
)
|
||||||
|
|
||||||
|
constrainContainerWidth(context, container)
|
||||||
|
return dialog
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun constrainContainerWidth(context: Context, container: LinearLayout) {
|
||||||
|
try {
|
||||||
|
val maxW = (context.resources.displayMetrics.widthPixels * 0.9).toInt()
|
||||||
|
val lp = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||||
|
container.layoutParams = lp
|
||||||
|
try {
|
||||||
|
container.minimumWidth = maxW
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setDialogWindowWidth(dialog: AlertDialog, context: Context) {
|
||||||
|
try {
|
||||||
|
val screenW = context.resources.displayMetrics.widthPixels
|
||||||
|
val maxByPercent = (screenW * 0.9).toInt()
|
||||||
|
val maxByDp = LogBoxUtils.dpToPx(context, 420)
|
||||||
|
val targetW = if (maxByPercent < maxByDp) maxByPercent else maxByDp
|
||||||
|
|
||||||
|
dialog.window?.setLayout(targetW, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||||
|
|
||||||
|
try {
|
||||||
|
currentMenuContainer?.let { container ->
|
||||||
|
val lp = container.layoutParams ?: ViewGroup.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
)
|
||||||
|
lp.width = ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
container.layoutParams = lp
|
||||||
|
try {
|
||||||
|
container.minimumWidth = targetW
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
container.post {
|
||||||
|
try {
|
||||||
|
container.requestLayout()
|
||||||
|
container.invalidate()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleMenuSelection(context: Context, index: Int) {
|
||||||
|
when (index) {
|
||||||
|
0 -> LogBoxActions.toggleSafeMode(context)
|
||||||
|
1 -> showConfirmAction(
|
||||||
|
context, "Refetch Bundle",
|
||||||
|
"This will download the latest bundle from Github."
|
||||||
|
) { LogBoxActions.refetchBundle(context) }
|
||||||
|
|
||||||
|
2 -> showCustomBundleDialog(context)
|
||||||
|
3 -> Utils.reloadApp()
|
||||||
|
4 -> showConfirmAction(
|
||||||
|
context, "Clear Cache & Reset",
|
||||||
|
"This will clear all cached bundles and reset to default settings."
|
||||||
|
) { LogBoxActions.clearCacheAndReset(context) }
|
||||||
|
|
||||||
|
// Options submenu
|
||||||
|
5 -> showOptionsMenu(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,205 @@
|
||||||
|
package GoonXposed.xposed.modules.LogBox
|
||||||
|
|
||||||
|
import GoonXposed.xposed.Module
|
||||||
|
import GoonXposed.xposed.Utils.Log
|
||||||
|
import GoonXposed.xposed.Utils.Companion.reloadApp
|
||||||
|
import android.content.Context
|
||||||
|
import de.robv.android.xposed.XC_MethodHook
|
||||||
|
import de.robv.android.xposed.XC_MethodReplacement
|
||||||
|
import de.robv.android.xposed.XposedBridge
|
||||||
|
import de.robv.android.xposed.XposedHelpers
|
||||||
|
import de.robv.android.xposed.callbacks.XC_LoadPackage
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
|
||||||
|
object LogBoxModule : Module() {
|
||||||
|
lateinit var packageParam: XC_LoadPackage.LoadPackageParam
|
||||||
|
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||||
|
var contextForMenu: Context? = null
|
||||||
|
|
||||||
|
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
|
||||||
|
this@LogBoxModule.packageParam = packageParam
|
||||||
|
|
||||||
|
try {
|
||||||
|
val dcdReactNativeHostClass = classLoader.loadClass("com.discord.bridge.DCDReactNativeHost")
|
||||||
|
val getUseDeveloperSupportMethod =
|
||||||
|
dcdReactNativeHostClass.methods.first { it.name == "getUseDeveloperSupport" }
|
||||||
|
|
||||||
|
// NOTE: This used to call a custom `.hook { before { result = true } }` DSL that
|
||||||
|
// relied on a shared extensions file which broke (unresolved references).
|
||||||
|
// Rewritten using the standard XposedBridge/XC_MethodHook API, which is already
|
||||||
|
// used elsewhere in this file, to force this method to always return true.
|
||||||
|
XposedBridge.hookMethod(getUseDeveloperSupportMethod, object : XC_MethodHook() {
|
||||||
|
override fun beforeHookedMethod(param: MethodHookParam) {
|
||||||
|
param.result = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
Log.e("Successfully hooked DCDReactNativeHost")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("Failed to hook DCDReactNativeHost: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
return@with
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onContext(context: Context) {
|
||||||
|
try {
|
||||||
|
Log.e("onContext called with context: $context")
|
||||||
|
contextForMenu = context
|
||||||
|
|
||||||
|
val possibleClasses = listOf(
|
||||||
|
"com.facebook.react.devsupport.BridgeDevSupportManager",
|
||||||
|
"com.facebook.react.devsupport.BridgelessDevSupportManager",
|
||||||
|
"com.facebook.react.devsupport.DevSupportManagerImpl",
|
||||||
|
"com.facebook.react.devsupport.DevSupportManagerBase",
|
||||||
|
"com.facebook.react.devsupport.DefaultDevSupportManager"
|
||||||
|
)
|
||||||
|
|
||||||
|
var foundAny = false
|
||||||
|
possibleClasses.forEach { className ->
|
||||||
|
try {
|
||||||
|
val clazz = packageParam.classLoader.loadClass(className)
|
||||||
|
Log.e("Found class: $className")
|
||||||
|
hookDevSupportManager(clazz, context)
|
||||||
|
foundAny = true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("Class not found: $className - ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!foundAny) {
|
||||||
|
tryFindDevSupportClasses(context)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Handle exception
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tryFindDevSupportClasses(context: Context) {
|
||||||
|
try {
|
||||||
|
val dexFile = packageParam.classLoader.javaClass.getDeclaredField("pathList")
|
||||||
|
dexFile.isAccessible = true
|
||||||
|
Log.e("Searching for DevSupport classes in classloader...")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("Could not search for classes: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hookDevSupportManager(clazz: Class<*>, context: Context) {
|
||||||
|
Log.e("Attempting to hook ${clazz.name}")
|
||||||
|
|
||||||
|
Log.e("Available methods in ${clazz.simpleName}:")
|
||||||
|
clazz.methods.forEach { method ->
|
||||||
|
if (method.name.contains("Dev") || method.name.contains("Reload") || method.name.contains("Options")) {
|
||||||
|
Log.e(" - ${method.name}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
val handleReloadJSMethod = clazz.methods.firstOrNull { it.name == "handleReloadJS" }
|
||||||
|
if (handleReloadJSMethod != null) {
|
||||||
|
XposedBridge.hookMethod(handleReloadJSMethod, object : XC_MethodReplacement() {
|
||||||
|
override fun replaceHookedMethod(param: MethodHookParam): Any? {
|
||||||
|
Log.e("handleReloadJS called - reloading app")
|
||||||
|
reloadApp()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("Failed to hook handleReloadJS: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val showDevOptionsDialogMethod = clazz.methods.firstOrNull { it.name == "showDevOptionsDialog" }
|
||||||
|
if (showDevOptionsDialogMethod != null) {
|
||||||
|
XposedBridge.hookMethod(showDevOptionsDialogMethod, object : XC_MethodReplacement() {
|
||||||
|
override fun replaceHookedMethod(param: MethodHookParam): Any? {
|
||||||
|
try {
|
||||||
|
var activityContext: Context? = null
|
||||||
|
try {
|
||||||
|
activityContext = getContextFromDevSupport(clazz, param.thisObject)
|
||||||
|
if (activityContext != null) {
|
||||||
|
Log.e("Successfully got context from DevSupport")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("Failed to get context from DevSupport (non-fatal): ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
val finalContext = activityContext ?: contextForMenu ?: context
|
||||||
|
Log.e("Using context: $finalContext (type: ${finalContext.javaClass.name})")
|
||||||
|
|
||||||
|
LogBoxNavigation.showRecoveryMenu(finalContext)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Handle exception
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Handle exception
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getContextFromDevSupport(clazz: Class<*>, instance: Any?): Context? {
|
||||||
|
if (instance == null) {
|
||||||
|
Log.e("getContextFromDevSupport: instance is null")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val helpers = listOf(
|
||||||
|
"mReactInstanceDevHelper",
|
||||||
|
"reactInstanceDevHelper",
|
||||||
|
"mReactInstanceManager",
|
||||||
|
"mApplicationContext"
|
||||||
|
)
|
||||||
|
|
||||||
|
for (helperName in helpers) {
|
||||||
|
try {
|
||||||
|
Log.e("Trying field: $helperName")
|
||||||
|
val helperField = XposedHelpers.findFieldIfExists(clazz, helperName)
|
||||||
|
if (helperField == null) {
|
||||||
|
Log.e("Field $helperName not found, skipping")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
val helper = helperField.get(instance)
|
||||||
|
if (helper == null) {
|
||||||
|
Log.e("Field $helperName is null, skipping")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (helper is Context) {
|
||||||
|
Log.e("Field $helperName is a Context, returning it")
|
||||||
|
return helper
|
||||||
|
}
|
||||||
|
|
||||||
|
val getCurrentActivityMethod = helper.javaClass.methods.firstOrNull {
|
||||||
|
it.name == "getCurrentActivity"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getCurrentActivityMethod != null) {
|
||||||
|
val ctx = getCurrentActivityMethod.invoke(helper) as? Context
|
||||||
|
if (ctx != null) {
|
||||||
|
Log.e("Got context from $helperName.getCurrentActivity()")
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("Error trying $helperName: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.e("Could not get context from DevSupport object using any method")
|
||||||
|
null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("Failed to get context (outer catch): ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package GoonXposed.xposed.modules.LogBox
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
object LogBoxNavigation {
|
||||||
|
fun showRecoveryMenu(context: Context) {
|
||||||
|
LogBoxMenu.showRecoveryMenu(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
package GoonXposed.xposed.modules.LogBox
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Color
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.File
|
||||||
|
import GoonXposed.xposed.Utils.Log
|
||||||
|
|
||||||
|
object LogBoxTheme {
|
||||||
|
|
||||||
|
fun isDarkMode(context: Context): Boolean {
|
||||||
|
try {
|
||||||
|
val settingsFile = File(context.filesDir, "logbox/LOGBOX_SETTINGS")
|
||||||
|
if (settingsFile.exists()) {
|
||||||
|
val json = JSONObject(settingsFile.readText())
|
||||||
|
val mode = json.optString("appearanceMode", "")
|
||||||
|
if (mode == "light") return false
|
||||||
|
if (mode == "dark") return true
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("Error reading LogBox appearance settings: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (context.resources.configuration.uiMode and
|
||||||
|
android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
|
||||||
|
android.content.res.Configuration.UI_MODE_NIGHT_YES
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getM3Colors(context: Context): M3Colors {
|
||||||
|
|
||||||
|
val isDark = isDarkMode(context)
|
||||||
|
val base = if (isDark) {
|
||||||
|
M3Colors(
|
||||||
|
surface = Color.parseColor("#1C1B1F"),
|
||||||
|
surfaceVariant = Color.parseColor("#49454F"),
|
||||||
|
onSurface = Color.parseColor("#E6E1E5"),
|
||||||
|
onSurfaceVariant = Color.parseColor("#CAC4D0"),
|
||||||
|
primary = Color.parseColor("#D0BCFF"),
|
||||||
|
onPrimary = Color.parseColor("#381E72"),
|
||||||
|
primaryContainer = Color.parseColor("#4F378B"),
|
||||||
|
onPrimaryContainer = Color.parseColor("#EADDFF"),
|
||||||
|
secondaryContainer = Color.parseColor("#4A4458"),
|
||||||
|
onSecondaryContainer = Color.parseColor("#E8DEF8"),
|
||||||
|
error = Color.parseColor("#F2B8B5"),
|
||||||
|
onError = Color.parseColor("#601410")
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
M3Colors(
|
||||||
|
surface = Color.parseColor("#FFFBFE"),
|
||||||
|
surfaceVariant = Color.parseColor("#E7E0EC"),
|
||||||
|
onSurface = Color.parseColor("#1C1B1F"),
|
||||||
|
onSurfaceVariant = Color.parseColor("#49454F"),
|
||||||
|
primary = Color.parseColor("#6750A4"),
|
||||||
|
onPrimary = Color.parseColor("#FFFFFF"),
|
||||||
|
primaryContainer = Color.parseColor("#EADDFF"),
|
||||||
|
onPrimaryContainer = Color.parseColor("#21005D"),
|
||||||
|
secondaryContainer = Color.parseColor("#E8DEF8"),
|
||||||
|
onSecondaryContainer = Color.parseColor("#1D192B"),
|
||||||
|
error = Color.parseColor("#B3261E"),
|
||||||
|
onError = Color.parseColor("#FFFFFF")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var flavor = "mocha"
|
||||||
|
|
||||||
|
try {
|
||||||
|
val themeFile = File(context.filesDir, "logbox/LOGBOX_THEMES")
|
||||||
|
if (themeFile.exists()) {
|
||||||
|
val json = JSONObject(themeFile.readText())
|
||||||
|
val f = json.optString("menuFlavor", json.optString("flavor", json.optString("id", ""))).lowercase()
|
||||||
|
if (f.isNotEmpty()) flavor = f
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flavor.isNotEmpty()) {
|
||||||
|
val pair = LogBoxConstants.FLAVOR_COLORS[flavor]
|
||||||
|
if (pair != null) {
|
||||||
|
val primary = Color.parseColor(pair.first)
|
||||||
|
val primaryContainer = Color.parseColor(pair.second)
|
||||||
|
val onPrimary = if (isDark) Color.WHITE else Color.WHITE
|
||||||
|
val onPrimaryContainer = if (isDark) Color.BLACK else Color.BLACK
|
||||||
|
return base.copy(
|
||||||
|
primary = primary,
|
||||||
|
primaryContainer = primaryContainer,
|
||||||
|
onPrimary = onPrimary,
|
||||||
|
onPrimaryContainer = onPrimaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("Error applying LogBox flavor: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
package GoonXposed.xposed.modules.LogBox
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.TypedValue
|
||||||
|
import android.graphics.drawable.GradientDrawable
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.content.res.ColorStateList
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.Toast
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.File
|
||||||
|
import GoonXposed.xposed.Constants
|
||||||
|
import GoonXposed.xposed.Utils.Log
|
||||||
|
|
||||||
|
object LogBoxUtils {
|
||||||
|
|
||||||
|
fun dpToPx(context: Context, dp: Int): Int {
|
||||||
|
return TypedValue.applyDimension(
|
||||||
|
TypedValue.COMPLEX_UNIT_DIP,
|
||||||
|
dp.toFloat(),
|
||||||
|
context.resources.displayMetrics
|
||||||
|
).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createM3Background(context: Context, color: Int, cornerRadius: Float): GradientDrawable {
|
||||||
|
return GradientDrawable().apply {
|
||||||
|
setColor(color)
|
||||||
|
setCornerRadius(dpToPx(context, cornerRadius.toInt()).toFloat())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPyoncordDirectory(context: Context): File {
|
||||||
|
val dir = File(context.filesDir, "pyoncord")
|
||||||
|
if (!dir.exists()) {
|
||||||
|
dir.mkdirs()
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showM3Toast(context: Context, message: String) {
|
||||||
|
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAppearanceMode(context: Context): String {
|
||||||
|
return try {
|
||||||
|
val settingsFile = File(context.filesDir, "logbox/LOGBOX_SETTINGS")
|
||||||
|
if (!settingsFile.exists()) return "system"
|
||||||
|
val json = JSONObject(settingsFile.readText())
|
||||||
|
json.optString("appearanceMode", "system")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("Error reading appearance mode: ${e.message}")
|
||||||
|
"system"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAppearanceMode(context: Context, mode: String) {
|
||||||
|
try {
|
||||||
|
val settingsFile = File(context.filesDir, "logbox/LOGBOX_SETTINGS")
|
||||||
|
settingsFile.parentFile?.mkdirs()
|
||||||
|
val settings = if (settingsFile.exists()) JSONObject(settingsFile.readText()) else JSONObject()
|
||||||
|
settings.put("appearanceMode", mode)
|
||||||
|
settingsFile.writeText(settings.toString())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogBoxActions.showError(context, "Failed to set appearance", e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveMenuFlavor(context: Context, flavor: String) {
|
||||||
|
try {
|
||||||
|
val themeFile = File(context.filesDir, "logbox/LOGBOX_THEMES")
|
||||||
|
themeFile.parentFile?.mkdirs()
|
||||||
|
|
||||||
|
val themeJson = JSONObject().apply {
|
||||||
|
put("menuFlavor", flavor)
|
||||||
|
}
|
||||||
|
|
||||||
|
themeFile.writeText(themeJson.toString())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("Error saving menu flavor: ${e.message}")
|
||||||
|
LogBoxActions.showError(context, "Failed to save flavor", e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isBundleInjectionDisabled(context: Context): Boolean {
|
||||||
|
return try {
|
||||||
|
val cacheDir = File(context.dataDir, Constants.CACHE_DIR)
|
||||||
|
val bundle = File(cacheDir, Constants.MAIN_SCRIPT_FILE)
|
||||||
|
val disabled = File(cacheDir, "${Constants.MAIN_SCRIPT_FILE}.disabled")
|
||||||
|
|
||||||
|
if (disabled.exists()) {
|
||||||
|
if (bundle.exists()) {
|
||||||
|
bundle.delete()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (bundle.exists()) return false
|
||||||
|
|
||||||
|
// If neither file exists, check settings
|
||||||
|
val settingsFile = File(context.filesDir, "logbox/LOGBOX_SETTINGS")
|
||||||
|
if (!settingsFile.exists()) return false
|
||||||
|
val json = JSONObject(settingsFile.readText())
|
||||||
|
json.optBoolean("bundleInjectionDisabled", false)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("Error reading injection setting: ${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
package GoonXposed.xposed.modules.LogBox
|
||||||
|
|
||||||
|
import android.app.AlertDialog
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
object OptionsMenu {
|
||||||
|
|
||||||
|
fun showOptionsMenu(context: Context) {
|
||||||
|
val colors = LogBoxTheme.getM3Colors(context)
|
||||||
|
lateinit var dialog: AlertDialog
|
||||||
|
|
||||||
|
val container = LogBoxComponents.createMenuContainer(context, colors, 20)
|
||||||
|
container.addView(LogBoxComponents.createTitle(context, colors, "Options", center = true))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
val injectionDisabled = LogBoxUtils.isBundleInjectionDisabled(context)
|
||||||
|
val injectionText = if (injectionDisabled) "Enable Bundle Injection" else "Disable Bundle Injection"
|
||||||
|
container.addView(LogBoxComponents.createM3Button(context, injectionText, colors) {
|
||||||
|
dialog.dismiss()
|
||||||
|
LogBoxActions.toggleBundleInjection(context)
|
||||||
|
GoonXposed.xposed.Utils.Companion.reloadApp()
|
||||||
|
})
|
||||||
|
|
||||||
|
container.addView(LogBoxComponents.createM3Button(context, "Refetch Bundle", colors) {
|
||||||
|
dialog.dismiss()
|
||||||
|
RecoveryMenu.showConfirmAction(
|
||||||
|
context,
|
||||||
|
"Refetch Bundle",
|
||||||
|
"This will download the latest bundle from Github."
|
||||||
|
) {
|
||||||
|
LogBoxActions.refetchBundle(context)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
container.addView(LogBoxComponents.createM3Button(context, "Clear Cache & Reset", colors) {
|
||||||
|
dialog.dismiss()
|
||||||
|
RecoveryMenu.showConfirmAction(
|
||||||
|
context,
|
||||||
|
"Clear Cache & Reset",
|
||||||
|
"This will clear all cached bundles and reset to default settings."
|
||||||
|
) {
|
||||||
|
LogBoxActions.clearCacheAndReset(context)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
container.addView(LogBoxComponents.createM3Button(context, "Back", colors) {
|
||||||
|
dialog.dismiss()
|
||||||
|
RecoveryMenu.showRecoveryMenu(context)
|
||||||
|
})
|
||||||
|
|
||||||
|
dialog = LogBoxDialogs.createDialog(context, container)
|
||||||
|
LogBoxDialogs.currentMenuContainer = container
|
||||||
|
dialog.show()
|
||||||
|
LogBoxDialogs.setDialogWindowWidth(dialog, context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
package GoonXposed.xposed.modules.LogBox
|
||||||
|
|
||||||
|
import android.app.AlertDialog
|
||||||
|
import android.content.Context
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import GoonXposed.xposed.Utils.Log
|
||||||
|
|
||||||
|
object RecoveryMenu {
|
||||||
|
|
||||||
|
fun showRecoveryMenu(context: Context) {
|
||||||
|
Log.e("showRecoveryMenu called with context: $context")
|
||||||
|
try {
|
||||||
|
val colors = LogBoxTheme.getM3Colors(context)
|
||||||
|
lateinit var dialog: AlertDialog
|
||||||
|
|
||||||
|
val container = LogBoxComponents.createMenuContainer(context, colors)
|
||||||
|
|
||||||
|
container.addView(LogBoxComponents.createTitle(context, colors, "GoonXposed Recovery", center = true))
|
||||||
|
container.addView(LogBoxComponents.createSubtitle(context, colors, "Select an option to continue"))
|
||||||
|
|
||||||
|
val safeModeText = if (LogBoxUtils.isSafeModeEnabled(context)) "Disable Safe Mode" else "Enable Safe Mode"
|
||||||
|
container.addView(LogBoxComponents.createM3Button(context, safeModeText, colors) {
|
||||||
|
dialog.dismiss()
|
||||||
|
handleMenuSelection(context, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
container.addView(LogBoxComponents.createM3Button(context, "Load Custom Bundle", colors) {
|
||||||
|
dialog.dismiss()
|
||||||
|
handleMenuSelection(context, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
container.addView(LogBoxComponents.createM3Button(context, "Reload App", colors) {
|
||||||
|
dialog.dismiss()
|
||||||
|
handleMenuSelection(context, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
val optionsBtn = LogBoxComponents.createM3Button(context, "Options", colors) {
|
||||||
|
dialog.dismiss()
|
||||||
|
handleMenuSelection(context, 5)
|
||||||
|
}
|
||||||
|
(optionsBtn.layoutParams as? LinearLayout.LayoutParams)?.let { lp ->
|
||||||
|
lp.setMargins(lp.leftMargin, LogBoxUtils.dpToPx(context, 16), lp.rightMargin, lp.bottomMargin)
|
||||||
|
optionsBtn.layoutParams = lp
|
||||||
|
}
|
||||||
|
container.addView(optionsBtn)
|
||||||
|
|
||||||
|
dialog = LogBoxDialogs.createDialog(context, container)
|
||||||
|
LogBoxDialogs.currentMenuContainer = container
|
||||||
|
dialog.show()
|
||||||
|
LogBoxDialogs.setDialogWindowWidth(dialog, context)
|
||||||
|
Log.e("Recovery menu shown successfully")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("Error showing recovery menu: ${e.message}", e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleMenuSelection(context: Context, index: Int) {
|
||||||
|
when (index) {
|
||||||
|
0 -> LogBoxActions.toggleSafeMode(context)
|
||||||
|
1 -> showConfirmAction(context, "Refetch Bundle", "This will download the latest bundle from Github.") {
|
||||||
|
LogBoxActions.refetchBundle(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
2 -> CustomBundleDialog.showCustomBundleDialog(context)
|
||||||
|
3 -> GoonXposed.xposed.Utils.Companion.reloadApp()
|
||||||
|
4 -> showConfirmAction(
|
||||||
|
context,
|
||||||
|
"Clear Cache & Reset",
|
||||||
|
"This will clear all cached bundles and reset to default settings."
|
||||||
|
) {
|
||||||
|
LogBoxActions.clearCacheAndReset(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
5 -> OptionsMenu.showOptionsMenu(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showConfirmAction(context: Context, title: String, message: String, action: () -> Unit) {
|
||||||
|
val colors = LogBoxTheme.getM3Colors(context)
|
||||||
|
val container = LogBoxComponents.createMenuContainer(context, colors)
|
||||||
|
|
||||||
|
container.addView(LogBoxComponents.createTitle(context, colors, title))
|
||||||
|
|
||||||
|
val messageView = android.widget.TextView(context).apply {
|
||||||
|
text = message
|
||||||
|
textSize = 14f
|
||||||
|
setTextColor(colors.onSurfaceVariant)
|
||||||
|
setPadding(0, 0, 0, LogBoxUtils.dpToPx(context, 24))
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
|
||||||
|
lineHeight = LogBoxUtils.dpToPx(context, 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
container.addView(messageView)
|
||||||
|
|
||||||
|
val buttonContainer = LinearLayout(context).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
gravity = android.view.Gravity.END
|
||||||
|
}
|
||||||
|
|
||||||
|
val cancelButton = android.widget.TextView(context).apply {
|
||||||
|
text = "Cancel"
|
||||||
|
setTextColor(colors.primary)
|
||||||
|
textSize = 14f
|
||||||
|
typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
|
||||||
|
setPadding(
|
||||||
|
LogBoxUtils.dpToPx(context, 16),
|
||||||
|
LogBoxUtils.dpToPx(context, 10),
|
||||||
|
LogBoxUtils.dpToPx(context, 16),
|
||||||
|
LogBoxUtils.dpToPx(context, 10)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val confirmButton = android.widget.TextView(context).apply {
|
||||||
|
text = "Confirm"
|
||||||
|
setTextColor(colors.primary)
|
||||||
|
textSize = 14f
|
||||||
|
typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
|
||||||
|
setPadding(
|
||||||
|
LogBoxUtils.dpToPx(context, 16),
|
||||||
|
LogBoxUtils.dpToPx(context, 10),
|
||||||
|
LogBoxUtils.dpToPx(context, 16),
|
||||||
|
LogBoxUtils.dpToPx(context, 10)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val dialog = LogBoxDialogs.createDialog(context, container)
|
||||||
|
|
||||||
|
cancelButton.setOnClickListener { dialog.dismiss() }
|
||||||
|
confirmButton.setOnClickListener {
|
||||||
|
dialog.dismiss()
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
|
||||||
|
buttonContainer.addView(cancelButton)
|
||||||
|
buttonContainer.addView(confirmButton)
|
||||||
|
container.addView(buttonContainer)
|
||||||
|
|
||||||
|
LogBoxDialogs.currentMenuContainer = container
|
||||||
|
dialog.show()
|
||||||
|
LogBoxDialogs.setDialogWindowWidth(dialog, context)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,84 @@
|
||||||
|
package GoonXposed.xposed.modules
|
||||||
|
|
||||||
|
import android.content.res.AssetManager
|
||||||
|
import de.robv.android.xposed.XC_MethodHook
|
||||||
|
import de.robv.android.xposed.XposedBridge
|
||||||
|
import de.robv.android.xposed.callbacks.XC_LoadPackage
|
||||||
|
import GoonXposed.xposed.BuildConfig
|
||||||
|
import GoonXposed.xposed.Constants
|
||||||
|
import GoonXposed.xposed.MethodHookBuilder
|
||||||
|
import GoonXposed.xposed.Module
|
||||||
|
import GoonXposed.xposed.Utils.Companion.JSON
|
||||||
|
import GoonXposed.xposed.hook
|
||||||
|
import GoonXposed.xposed.method
|
||||||
|
import GoonXposed.xposed.safeLoadClass
|
||||||
|
import GoonXposed.xposed.modules.HookScriptLoaderModule.PRELOADS_DIR
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
// TODO: Remove this redundant module and merge its functionality into AdditionalBridgeMethodsModule if needed
|
||||||
|
class PayloadGlobalModule(private val modules: List<Module>) : Module() {
|
||||||
|
private companion object {
|
||||||
|
const val GLOBAL_NAME = "__PYON_LOADER__"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getPayloadString(): String = JSON.encodeToString(
|
||||||
|
buildJsonObject {
|
||||||
|
put("loaderName", Constants.LOADER_NAME)
|
||||||
|
put("loaderVersion", BuildConfig.VERSION_NAME)
|
||||||
|
@Suppress("DEPRECATION") for (module in modules) module.buildPayload(this)
|
||||||
|
})
|
||||||
|
|
||||||
|
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
|
||||||
|
val catalystInstance = classLoader.safeLoadClass("com.facebook.react.bridge.CatalystInstanceImpl")
|
||||||
|
val scriptLoader = classLoader.safeLoadClass("com.facebook.react.runtime.ReactInstance\$loadJSBundle$1")
|
||||||
|
?: classLoader.safeLoadClass("com.facebook.react.runtime.ReactInstance$1")
|
||||||
|
|
||||||
|
val setGlobalVariable: (XC_MethodHook.MethodHookParam, String, String) -> Unit = { param, key, json ->
|
||||||
|
runCatching {
|
||||||
|
// Attempt old CatalystInstanceImpl method
|
||||||
|
catalystInstance!!.method(
|
||||||
|
"setGlobalVariable", String::class.java, String::class.java
|
||||||
|
).invoke(param.thisObject, key, json)
|
||||||
|
}.onFailure {
|
||||||
|
// Failed (likely because it's a stub), use a workaround
|
||||||
|
val preloadsDir = File("${appInfo.dataDir}/${Constants.FILES_DIR}", PRELOADS_DIR)
|
||||||
|
File(preloadsDir, "rv_globals_$key.js").apply {
|
||||||
|
writeText("this[${JSON.encodeToString(key)}]=$json")
|
||||||
|
|
||||||
|
XposedBridge.invokeOriginalMethod(
|
||||||
|
scriptLoader!!.method(
|
||||||
|
"loadScriptFromFile",
|
||||||
|
String::class.java,
|
||||||
|
String::class.java,
|
||||||
|
Boolean::class.javaPrimitiveType!!
|
||||||
|
), param.thisObject, arrayOf(absolutePath, absolutePath, param.args[2])
|
||||||
|
)
|
||||||
|
|
||||||
|
delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val hook = MethodHookBuilder().run {
|
||||||
|
before {
|
||||||
|
setGlobalVariable(param, GLOBAL_NAME, getPayloadString())
|
||||||
|
}
|
||||||
|
|
||||||
|
build()
|
||||||
|
}
|
||||||
|
|
||||||
|
listOf(catalystInstance, scriptLoader).forEach { if (it != null) hook(it, hook) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hook(instance: Class<*>, hook: XC_MethodHook) {
|
||||||
|
instance.method(
|
||||||
|
"loadScriptFromAssets", AssetManager::class.java, String::class.java, Boolean::class.javaPrimitiveType!!,
|
||||||
|
).hook(hook)
|
||||||
|
|
||||||
|
instance.method(
|
||||||
|
"loadScriptFromFile", String::class.java, String::class.java, Boolean::class.javaPrimitiveType!!
|
||||||
|
).hook(hook)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,317 @@
|
||||||
|
package GoonXposed.xposed.modules
|
||||||
|
|
||||||
|
import de.robv.android.xposed.callbacks.XC_LoadPackage
|
||||||
|
import kotlinx.serialization.json.JsonObjectBuilder
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
import GoonXposed.xposed.Constants
|
||||||
|
import GoonXposed.xposed.Module
|
||||||
|
import GoonXposed.xposed.Utils.Log
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PerfPatchesModule
|
||||||
|
*
|
||||||
|
* Writes a small JavaScript preload into the module's `preloads` directory.
|
||||||
|
* The preload performs guarded, best-effort runtime monkeypatches to:
|
||||||
|
* - add memoization to expensive pure functions (if present)
|
||||||
|
* - cache RowGenerator.generate outputs on instances when inputs look stable
|
||||||
|
* - batch analytics/log events to avoid high-frequency synchronous work
|
||||||
|
*
|
||||||
|
* The implementation is intentionally defensive: it never throws and only applies
|
||||||
|
* patches if the targeted symbols exist.
|
||||||
|
*/
|
||||||
|
object PerfPatchesModule : Module() {
|
||||||
|
private const val FILE_NAME = "perf_patches.js"
|
||||||
|
|
||||||
|
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) {
|
||||||
|
try {
|
||||||
|
// Build preloads path inside the app data files directory
|
||||||
|
val filesDir = File(packageParam.appInfo.dataDir, Constants.FILES_DIR).apply { mkdirs() }
|
||||||
|
val preloadsDir = File(filesDir, HookScriptLoaderModule.PRELOADS_DIR).apply { mkdirs() }
|
||||||
|
val out = File(preloadsDir, FILE_NAME)
|
||||||
|
|
||||||
|
// Defensive JS runtime patch. Avoid template literals with ${} to prevent Kotlin interpolation.
|
||||||
|
val js = """
|
||||||
|
(function(){
|
||||||
|
try {
|
||||||
|
if (!globalThis.__SHIGGY_PERF_PATCHES__) {
|
||||||
|
globalThis.__SHIGGY_PERF_PATCHES__ = { installedAt: Date.now(), enabled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function safe(fn) {
|
||||||
|
return function() {
|
||||||
|
try { return fn.apply(this, arguments); }
|
||||||
|
catch (e) { try { console && console.warn && console.warn('shiggy patch error', e); } catch(_){} }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function memoize(fn, maxEntries) {
|
||||||
|
if (typeof fn !== 'function') return fn;
|
||||||
|
var cache = new Map();
|
||||||
|
maxEntries = Number(maxEntries) || 128;
|
||||||
|
return function() {
|
||||||
|
var key;
|
||||||
|
try { key = JSON.stringify(Array.prototype.slice.call(arguments)); }
|
||||||
|
catch (e) { key = String(arguments.length) + ':' + String(arguments[0]); }
|
||||||
|
if (cache.has(key)) return cache.get(key);
|
||||||
|
var res = fn.apply(this, arguments);
|
||||||
|
cache.set(key, res);
|
||||||
|
if (cache.size > maxEntries) {
|
||||||
|
var it = cache.keys().next();
|
||||||
|
if (!it.done) cache.delete(it.value);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBatched(fn, intervalMs) {
|
||||||
|
if (typeof fn !== 'function') return fn;
|
||||||
|
var buf = [];
|
||||||
|
var timer = null;
|
||||||
|
intervalMs = Number(intervalMs) || 2000;
|
||||||
|
function flush() {
|
||||||
|
var batch = buf.splice(0, buf.length);
|
||||||
|
if (!batch.length) return;
|
||||||
|
try { fn.call(null, batch); }
|
||||||
|
catch (e) {
|
||||||
|
// Fallback: call per event asynchronously
|
||||||
|
try {
|
||||||
|
batch.forEach(function(ev){
|
||||||
|
setTimeout(function(){ try { fn(ev); } catch (_) {} }, 0);
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return function(event) {
|
||||||
|
try {
|
||||||
|
buf.push(event);
|
||||||
|
if (!timer) timer = setTimeout(function(){ timer = null; flush(); }, intervalMs);
|
||||||
|
} catch (e) {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchIfExists(path, wrapper) {
|
||||||
|
try {
|
||||||
|
var parts = path.split('.');
|
||||||
|
var obj = globalThis;
|
||||||
|
for (var i = 0; i < parts.length - 1; i++) {
|
||||||
|
obj = obj && obj[parts[i]];
|
||||||
|
}
|
||||||
|
if (!obj) return false;
|
||||||
|
var key = parts[parts.length - 1];
|
||||||
|
if (typeof obj[key] !== 'function') return false;
|
||||||
|
obj[key] = wrapper(obj[key]);
|
||||||
|
return true;
|
||||||
|
} catch (e) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try memoizing a few well-known functions
|
||||||
|
['computeHappeningNowState', 'computeNowState'].forEach(function(name){
|
||||||
|
try { patchIfExists(name, function(orig){ return memoize(orig, 128); }); } catch(_) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Try caching RowGenerator.prototype.generate by instance signature
|
||||||
|
try {
|
||||||
|
var RG = globalThis && globalThis.RowGenerator;
|
||||||
|
if (RG && RG.prototype && typeof RG.prototype.generate === 'function') {
|
||||||
|
(function(){
|
||||||
|
var orig = RG.prototype.generate;
|
||||||
|
RG.prototype.generate = function() {
|
||||||
|
try {
|
||||||
|
var sig;
|
||||||
|
if (arguments && arguments.length) {
|
||||||
|
var a0 = arguments[0];
|
||||||
|
if (Array.isArray(a0)) {
|
||||||
|
sig = 'arr:' + a0.length + ':' + a0.slice(0,8).map(function(x){ return x && x.id ? x.id : ''; }).join(',');
|
||||||
|
} else if (a0 && a0.id) {
|
||||||
|
sig = 'id:' + a0.id;
|
||||||
|
} else {
|
||||||
|
try { sig = JSON.stringify(a0).slice(0,200); } catch(e){ sig = String(a0); }
|
||||||
|
}
|
||||||
|
} else sig = 'noargs';
|
||||||
|
|
||||||
|
if (this.__shiggy_sig === sig && typeof this.__shiggy_cache !== 'undefined') {
|
||||||
|
return this.__shiggy_cache;
|
||||||
|
}
|
||||||
|
var res = orig.apply(this, arguments);
|
||||||
|
this.__shiggy_sig = sig;
|
||||||
|
this.__shiggy_cache = res;
|
||||||
|
return res;
|
||||||
|
} catch (e) { return orig.apply(this, arguments); }
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
// Batch analytics-like methods (best-effort)
|
||||||
|
['app_ui_viewed', 'logEvent', 'Analytics.log', 'Analytics.track', 'analytics.trackEvent'].forEach(function(name){
|
||||||
|
try {
|
||||||
|
var parts = name.split('.');
|
||||||
|
var obj = globalThis;
|
||||||
|
for (var i = 0; i < parts.length - 1; i++) obj = obj && obj[parts[i]];
|
||||||
|
if (!obj) return;
|
||||||
|
var key = parts[parts.length - 1];
|
||||||
|
if (typeof obj[key] === 'function') obj[key] = createBatched(obj[key], 2000);
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Make console methods async to avoid blocking hot-paths during startup
|
||||||
|
(function(){
|
||||||
|
try {
|
||||||
|
if (typeof console !== 'undefined') {
|
||||||
|
['log','info','warn','error','debug','trace'].forEach(function(m){
|
||||||
|
try {
|
||||||
|
if (typeof console[m] === 'function') {
|
||||||
|
var orig = console[m];
|
||||||
|
console[m] = function() {
|
||||||
|
var args = Array.prototype.slice.call(arguments);
|
||||||
|
setTimeout(function(){ try { orig.apply(console, args); } catch(_){} }, 0);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch(e){}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch(e){}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Deduplicate and slightly delay repeated kv calls to reduce IO contention on startup
|
||||||
|
(function(){
|
||||||
|
try {
|
||||||
|
var pending = new Map();
|
||||||
|
// Lightweight stats for dedupe wrapper to help telemetry/debugging in-case we need to tune delays.
|
||||||
|
try {
|
||||||
|
globalThis.__SHIGGY_PERF_DEDUPE_STATS__ = globalThis.__SHIGGY_PERF_DEDUPE_STATS__ || {
|
||||||
|
methods: {},
|
||||||
|
totalRequests: 0,
|
||||||
|
totalHits: 0,
|
||||||
|
totalMisses: 0,
|
||||||
|
pending: 0,
|
||||||
|
lastUpdated: Date.now()
|
||||||
|
};
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
function _incStats(name, key) {
|
||||||
|
try {
|
||||||
|
var s = globalThis.__SHIGGY_PERF_DEDUPE_STATS__;
|
||||||
|
if (!s) return;
|
||||||
|
s.lastUpdated = Date.now();
|
||||||
|
var m = s.methods[name] || (s.methods[name] = { requests: 0, hits: 0, misses: 0, pending: 0 });
|
||||||
|
if (key === 'requests') { m.requests++; s.totalRequests++; }
|
||||||
|
else if (key === 'hits') { m.hits++; s.totalHits++; }
|
||||||
|
else if (key === 'misses') { m.misses++; s.totalMisses++; }
|
||||||
|
else if (key === 'pending_inc') { m.pending++; s.pending++; }
|
||||||
|
else if (key === 'pending_dec') { m.pending = Math.max(0, m.pending - 1); s.pending = Math.max(0, s.pending - 1); }
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapAsyncDedupe(kvObj, methodName, delayMs){
|
||||||
|
try {
|
||||||
|
var orig = kvObj[methodName];
|
||||||
|
if (typeof orig !== 'function') return;
|
||||||
|
kvObj[methodName] = function() {
|
||||||
|
var argsArr = Array.prototype.slice.call(arguments);
|
||||||
|
var key;
|
||||||
|
try { key = JSON.stringify([methodName, argsArr]); } catch(e){ key = methodName + ':' + String(argsArr[0]); }
|
||||||
|
_incStats(methodName, 'requests');
|
||||||
|
if (pending.has(key)) { _incStats(methodName, 'hits'); return pending.get(key); }
|
||||||
|
_incStats(methodName, 'misses');
|
||||||
|
var self = this;
|
||||||
|
var p = new Promise(function(resolve, reject){
|
||||||
|
setTimeout(function(){
|
||||||
|
try {
|
||||||
|
var res = orig.apply(self, argsArr);
|
||||||
|
if (res && typeof res.then === 'function') res.then(resolve, reject);
|
||||||
|
else resolve(res);
|
||||||
|
} catch (e) { reject(e); }
|
||||||
|
}, Number(delayMs) || 20);
|
||||||
|
});
|
||||||
|
pending.set(key, p);
|
||||||
|
_incStats(methodName, 'pending_inc');
|
||||||
|
var cleanup = function(){ pending.delete(key); _incStats(methodName, 'pending_dec'); };
|
||||||
|
p.then(cleanup, cleanup);
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
} catch(e){}
|
||||||
|
}
|
||||||
|
if (globalThis.kv) {
|
||||||
|
if (globalThis.kv.get_many) wrapAsyncDedupe(globalThis.kv, 'get_many', 20);
|
||||||
|
if (globalThis.kv.get_kv_entries) wrapAsyncDedupe(globalThis.kv, 'get_kv_entries', 20);
|
||||||
|
}
|
||||||
|
} catch(e){}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Defer heavy filesystem/storage calls slightly to prioritize UI rendering
|
||||||
|
(function(){
|
||||||
|
try {
|
||||||
|
function makeDeferredIfAsync(obj, name, delayMs) {
|
||||||
|
try {
|
||||||
|
var orig = obj[name];
|
||||||
|
if (typeof orig !== 'function') return;
|
||||||
|
var isAsync = orig.constructor && orig.constructor.name === 'AsyncFunction';
|
||||||
|
var src = String(orig);
|
||||||
|
if (!isAsync && src.indexOf('return new Promise') === -1 && src.indexOf('.then(') === -1) {
|
||||||
|
// Likely synchronous - skip deferral to avoid changing semantics
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
obj[name] = function() {
|
||||||
|
var args = Array.prototype.slice.call(arguments);
|
||||||
|
var self = this;
|
||||||
|
return new Promise(function(resolve, reject){
|
||||||
|
setTimeout(function(){
|
||||||
|
try {
|
||||||
|
var res = orig.apply(self, args);
|
||||||
|
if (res && typeof res.then === 'function') res.then(resolve, reject);
|
||||||
|
else resolve(res);
|
||||||
|
} catch (e) { reject(e); }
|
||||||
|
}, Number(delayMs) || 100);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
} catch(e){}
|
||||||
|
}
|
||||||
|
if (globalThis.db && typeof globalThis.db.fs_info === 'function') {
|
||||||
|
makeDeferredIfAsync(globalThis.db, 'fs_info', 100);
|
||||||
|
}
|
||||||
|
if (globalThis.Storage && typeof globalThis.Storage.refresh === 'function') {
|
||||||
|
makeDeferredIfAsync(globalThis.Storage, 'refresh', 50);
|
||||||
|
}
|
||||||
|
} catch(e){}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Expose lightweight API
|
||||||
|
try {
|
||||||
|
if (!globalThis.__SHIGGY_PERF_API__) {
|
||||||
|
globalThis.__SHIGGY_PERF_API__ = {
|
||||||
|
installedAt: Date.now(),
|
||||||
|
enabled: true,
|
||||||
|
uninstall: function() { try { globalThis.__SHIGGY_PERF_API__.enabled = false; } catch(e) {} }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
try { console && console.warn && console.warn('PerfPatches: error', e); } catch(_) {}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
// Write file if absent or changed
|
||||||
|
try {
|
||||||
|
if (!out.exists() || out.readText() != js) {
|
||||||
|
out.writeText(js)
|
||||||
|
Log.i("PerfPatchesModule: wrote patch to " + out.absolutePath)
|
||||||
|
} else {
|
||||||
|
Log.i("PerfPatchesModule: perf patch already up-to-date")
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.e("PerfPatchesModule: failed writing patch", e)
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.e("PerfPatchesModule:onLoad failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated("Overrides deprecated Module.buildPayload")
|
||||||
|
override fun buildPayload(builder: JsonObjectBuilder) {
|
||||||
|
builder.put("perfPatches", true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
package GoonXposed.xposed.modules
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.AtomicFile
|
||||||
|
import GoonXposed.xposed.Module
|
||||||
|
import GoonXposed.xposed.Utils.Log
|
||||||
|
import GoonXposed.xposed.asFile
|
||||||
|
import GoonXposed.xposed.modules.bridge.BridgeModule
|
||||||
|
import java.io.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages the states of plugins, allowing reading and writing of plugin states to a file.
|
||||||
|
*
|
||||||
|
* ## Methods
|
||||||
|
*
|
||||||
|
* - `Shiggy.plugins.states.read(): { flags: { [pluginId: string]: number } }`
|
||||||
|
* - Reads the current plugin states from the file and returns them as a map.
|
||||||
|
*
|
||||||
|
* - `Shiggy.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 = "Shiggy/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("Shiggy.plugins.states.read") {
|
||||||
|
if (::states.isInitialized) states.toMap() else
|
||||||
|
PluginStates.loadFromFileOrNull(statesFile)?.let {
|
||||||
|
states = it
|
||||||
|
it.toMap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BridgeModule.registerMethod("Shiggy.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,176 @@
|
||||||
|
package GoonXposed.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 GoonXposed.xposed.Constants
|
||||||
|
import GoonXposed.xposed.Module
|
||||||
|
import GoonXposed.xposed.Utils
|
||||||
|
import GoonXposed.xposed.Utils.Companion.JSON
|
||||||
|
import GoonXposed.xposed.Utils.Companion.reloadApp
|
||||||
|
import GoonXposed.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
|
||||||
|
// Path to the application's data directory so we can read LogBox settings
|
||||||
|
private lateinit var appDataDir: 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://github.com/OTKUSteyler/GoonCord/releases/latest/download/gooncord.js"
|
||||||
|
|
||||||
|
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
|
||||||
|
// store app data dir for later checks (LogBox settings live under files/logbox)
|
||||||
|
appDataDir = File(appInfo.dataDir)
|
||||||
|
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 {
|
||||||
|
// Respect LogBox setting to disable bundle injections.
|
||||||
|
// If the file "files/logbox/LOGBOX_SETTINGS" contains the flag bundleInjectionDisabled=true,
|
||||||
|
// skip attempting to download/update the bundle.
|
||||||
|
try {
|
||||||
|
val settingsFile = File(appDataDir, "files/logbox/LOGBOX_SETTINGS")
|
||||||
|
if (settingsFile.exists()) {
|
||||||
|
val txt = settingsFile.readText()
|
||||||
|
// look for a simple true marker; this avoids introducing a new JSON dependency here
|
||||||
|
if (txt.contains("\"bundleInjectionDisabled\"") && txt.contains("true")) {
|
||||||
|
Log.i("UpdaterModule: bundle injection disabled by LogBox settings — skipping download")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (ignore: Exception) {
|
||||||
|
// If anything goes wrong reading the setting, proceed with normal behaviour.
|
||||||
|
}
|
||||||
|
|
||||||
|
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("GoonCord 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
|
||||||
|
|
||||||
|
activity.runOnUiThread {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,250 @@
|
||||||
|
@file:Suppress("DEPRECATION")
|
||||||
|
|
||||||
|
package GoonXposed.xposed.modules.appearance
|
||||||
|
|
||||||
|
import android.content.res.AssetManager
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import android.graphics.Typeface.CustomFallbackBuilder
|
||||||
|
import android.graphics.fonts.Font
|
||||||
|
import android.graphics.fonts.FontFamily
|
||||||
|
import android.os.Build
|
||||||
|
import de.robv.android.xposed.XC_MethodReplacement
|
||||||
|
import de.robv.android.xposed.XposedHelpers
|
||||||
|
import de.robv.android.xposed.callbacks.XC_LoadPackage
|
||||||
|
import GoonXposed.xposed.Constants
|
||||||
|
import GoonXposed.xposed.Module
|
||||||
|
import GoonXposed.xposed.Utils.Companion.JSON
|
||||||
|
import GoonXposed.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 kotlinx.serialization.json.JsonObjectBuilder
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
// NOTE: These extensions used to be resolved from a shared Utils/Extensions file,
|
||||||
|
// but that reference broke (renamed/removed/relocated), causing "Unresolved reference"
|
||||||
|
// build failures. Defining them locally here removes the dependency on that file.
|
||||||
|
// asFile(): ensures the parent directory of a file exists.
|
||||||
|
// asDir(): ensures the directory itself exists.
|
||||||
|
private fun File.asFile(): File = apply { parentFile?.mkdirs() }
|
||||||
|
private fun File.asDir(): File = apply { mkdirs() }
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class FontDefinition(
|
||||||
|
val name: String? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
val spec: Int? = null,
|
||||||
|
val main: Map<String, String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
object FontsModule : Module() {
|
||||||
|
private val EXTENSIONS = arrayOf("", "_bold", "_italic", "_bold_italic")
|
||||||
|
private val FILE_EXTENSIONS = arrayOf(".ttf", ".otf")
|
||||||
|
private const val FONTS_ASSET_PATH = "fonts/"
|
||||||
|
|
||||||
|
private lateinit var fontsDir: File
|
||||||
|
private lateinit var fontsDownloadsDir: File
|
||||||
|
private var fontsAbsPath: String? = null
|
||||||
|
|
||||||
|
override fun buildPayload(builder: JsonObjectBuilder) {
|
||||||
|
builder.apply {
|
||||||
|
put("fontPatch", 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
|
||||||
|
XposedHelpers.findAndHookMethod(
|
||||||
|
"com.facebook.react.common.assets.ReactFontManager\$Companion",
|
||||||
|
classLoader,
|
||||||
|
"createAssetTypeface",
|
||||||
|
String::class.java,
|
||||||
|
Int::class.java,
|
||||||
|
"android.content.res.AssetManager",
|
||||||
|
object : XC_MethodReplacement() {
|
||||||
|
override fun replaceHookedMethod(param: MethodHookParam): Typeface? {
|
||||||
|
val fontFamilyName: String = param.args[0].toString()
|
||||||
|
val style: Int = param.args[1] as Int
|
||||||
|
val assetManager: AssetManager = param.args[2] as AssetManager
|
||||||
|
return createAssetTypeface(fontFamilyName, style, assetManager)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
val fontDefFile = File(appInfo.dataDir, "${Constants.FILES_DIR}/fonts.json").apply { asFile() }
|
||||||
|
if (!fontDefFile.exists()) return@with
|
||||||
|
|
||||||
|
val fontDef = try {
|
||||||
|
JSON.decodeFromString<FontDefinition>(fontDefFile.readText())
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
return@with
|
||||||
|
}
|
||||||
|
|
||||||
|
fontsDownloadsDir = File(appInfo.dataDir, "${Constants.FILES_DIR}/downloads/fonts").apply { asDir() }
|
||||||
|
fontsDir = File(fontsDownloadsDir, fontDef.name!!).apply { asDir() }
|
||||||
|
fontsAbsPath = fontsDir.absolutePath + "/"
|
||||||
|
|
||||||
|
fontsDir.listFiles()?.forEach { file ->
|
||||||
|
val fileName = file.name
|
||||||
|
if (!fileName.startsWith(".")) {
|
||||||
|
val fontName = fileName.split('.')[0]
|
||||||
|
if (fontDef.main.keys.none { it == fontName }) {
|
||||||
|
Log.i("Deleting font file: $fileName")
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// These files should be downloaded by the JS side, but oh well
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
val client = HttpClient(CIO) {
|
||||||
|
install(UserAgent) { agent = Constants.USER_AGENT }
|
||||||
|
install(HttpTimeout) {
|
||||||
|
requestTimeoutMillis = 15_000
|
||||||
|
connectTimeoutMillis = 10_000
|
||||||
|
socketTimeoutMillis = 15_000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fontDef.main.keys.map { name ->
|
||||||
|
async {
|
||||||
|
val url = fontDef.main.getValue(name)
|
||||||
|
try {
|
||||||
|
val extension = FILE_EXTENSIONS.firstOrNull { url.substringBefore('?').endsWith(it) }
|
||||||
|
?: FILE_EXTENSIONS[0]
|
||||||
|
val file = File(fontsDir, "$name$extension").apply { asFile() }
|
||||||
|
if (file.exists()) return@async
|
||||||
|
|
||||||
|
Log.i("Downloading $name from $url")
|
||||||
|
val response: HttpResponse = client.get(url)
|
||||||
|
if (response.status == HttpStatusCode.OK) {
|
||||||
|
file.writeBytes(response.body())
|
||||||
|
} else {
|
||||||
|
Log.e("Failed to download fonts ($name from $url): HTTP ${response.status}")
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.e("Failed to download fonts ($name from $url)", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.awaitAll()
|
||||||
|
} finally {
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createAssetTypefaceWithFallbacks(
|
||||||
|
fontFamilyNames: Array<String>, style: Int, assetManager: AssetManager
|
||||||
|
): Typeface? {
|
||||||
|
val fontFamilies: MutableList<FontFamily> = ArrayList()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
// Iterate over the list of fontFamilyNames, constructing new FontFamily objects
|
||||||
|
// for use in the CustomFallbackBuilder below.
|
||||||
|
for (fontFamilyName in fontFamilyNames) {
|
||||||
|
try {
|
||||||
|
for (fileExtension in FILE_EXTENSIONS) {
|
||||||
|
val (customName, refName) = fontFamilyName.split(":")
|
||||||
|
val file = File(fontsDownloadsDir, "$customName/$refName.$fileExtension").apply { asFile() }
|
||||||
|
val font = Font.Builder(file).build()
|
||||||
|
val family = FontFamily.Builder(font).build()
|
||||||
|
fontFamilies.add(family)
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
}
|
||||||
|
|
||||||
|
for (fontRootPath in arrayOf(fontsAbsPath, FONTS_ASSET_PATH).filterNotNull()) {
|
||||||
|
for (fileExtension in FILE_EXTENSIONS) {
|
||||||
|
val fileName =
|
||||||
|
StringBuilder().append(fontRootPath).append(fontFamilyName).append(fileExtension).toString()
|
||||||
|
try {
|
||||||
|
val builder = if (fileName[0] == '/') Font.Builder(File(fileName)) else Font.Builder(
|
||||||
|
assetManager, fileName
|
||||||
|
)
|
||||||
|
val font = builder.build()
|
||||||
|
val family = FontFamily.Builder(font).build()
|
||||||
|
fontFamilies.add(family)
|
||||||
|
} catch (_: RuntimeException) {
|
||||||
|
// If the typeface asset does not exist, try another extension.
|
||||||
|
continue
|
||||||
|
} catch (_: IOException) {
|
||||||
|
// If the font asset does not exist, try another extension.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there's some problem constructing fonts, fall back to the default behavior.
|
||||||
|
if (fontFamilies.isEmpty()) return createAssetTypeface(fontFamilyNames[0], style, assetManager)
|
||||||
|
|
||||||
|
val fallbackBuilder = CustomFallbackBuilder(fontFamilies[0])
|
||||||
|
for (i in 1 until fontFamilies.size) {
|
||||||
|
fallbackBuilder.addCustomFallback(fontFamilies[i])
|
||||||
|
}
|
||||||
|
return fallbackBuilder.build()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createAssetTypeface(
|
||||||
|
fontFamilyName: String, style: Int, assetManager: AssetManager
|
||||||
|
): Typeface? {
|
||||||
|
// This logic attempts to safely check if the frontend code is attempting to use
|
||||||
|
// fallback fonts, and if it is, to use the fallback typeface creation logic.
|
||||||
|
var fontFamilyName: String = fontFamilyName
|
||||||
|
val fontFamilyNames = fontFamilyName.split(",".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
|
||||||
|
for (i in fontFamilyNames.indices) {
|
||||||
|
fontFamilyNames[i] = fontFamilyNames[i].trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there are multiple font family names:
|
||||||
|
// For newer versions of Android, construct a Typeface with fallbacks
|
||||||
|
// For older versions of Android, ignore all the fallbacks and just use the first font family
|
||||||
|
if (fontFamilyNames.size > 1) {
|
||||||
|
fontFamilyName = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
return createAssetTypefaceWithFallbacks(fontFamilyNames, style, assetManager)
|
||||||
|
} else {
|
||||||
|
fontFamilyNames[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val extension = EXTENSIONS[style]
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (fileExtension in FILE_EXTENSIONS) {
|
||||||
|
val (customName, refName) = fontFamilyName.split(":")
|
||||||
|
val file = File(fontsDownloadsDir, "$customName/$refName.$fileExtension").apply { asFile() }
|
||||||
|
if (!file.exists()) throw Exception()
|
||||||
|
return Typeface.createFromFile(file.absolutePath)
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lastly, after all those checks above, this is the original RN logic for
|
||||||
|
// getting the typeface.
|
||||||
|
for (fontRootPath in arrayOf(fontsAbsPath, FONTS_ASSET_PATH).filterNotNull()) {
|
||||||
|
for (fileExtension in FILE_EXTENSIONS) {
|
||||||
|
val fileName =
|
||||||
|
StringBuilder().append(fontRootPath).append(fontFamilyName).append(extension).append(fileExtension)
|
||||||
|
.toString()
|
||||||
|
|
||||||
|
return try {
|
||||||
|
if (fileName[0] == '/') Typeface.createFromFile(fileName)
|
||||||
|
else Typeface.createFromAsset(assetManager, fileName)
|
||||||
|
} catch (_: RuntimeException) {
|
||||||
|
// If the typeface asset does not exist, try another extension.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Typeface.create(fontFamilyName, style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
package GoonXposed.xposed.modules.appearance
|
||||||
|
|
||||||
|
import android.R.color
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import de.robv.android.xposed.XposedHelpers
|
||||||
|
import GoonXposed.xposed.Module
|
||||||
|
import kotlinx.serialization.json.*
|
||||||
|
import java.lang.ref.WeakReference
|
||||||
|
|
||||||
|
object SysColorsModule : Module() {
|
||||||
|
private lateinit var context: WeakReference<Context>
|
||||||
|
private fun isSupported() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||||
|
|
||||||
|
@Deprecated("This method is deprecated in the parent class")
|
||||||
|
override fun buildPayload(builder: JsonObjectBuilder) {
|
||||||
|
context = WeakReference(runCatching {
|
||||||
|
XposedHelpers.callStaticMethod(
|
||||||
|
XposedHelpers.findClass("android.app.ActivityThread", null), "currentApplication"
|
||||||
|
) as Context
|
||||||
|
}.getOrNull())
|
||||||
|
val accents = arrayOf("accent1", "accent2", "accent3", "neutral1", "neutral2")
|
||||||
|
val shades = arrayOf(0, 10, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000)
|
||||||
|
|
||||||
|
builder.apply {
|
||||||
|
put("isSysColorsSupported", isSupported())
|
||||||
|
if (isSupported()) putJsonObject("sysColors") {
|
||||||
|
for (accent in accents) putJsonArray(accent) {
|
||||||
|
for (shade in shades) {
|
||||||
|
val colorName = "system_" + accent + "_" + shade
|
||||||
|
|
||||||
|
val colorResourceId = runCatching {
|
||||||
|
color::class.java.getField(colorName).getInt(null)
|
||||||
|
}.getOrElse { 0 }
|
||||||
|
|
||||||
|
add(convertToColor(colorResourceId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun convertToColor(id: Int): String {
|
||||||
|
val ctx = context.get()
|
||||||
|
val clr = if (isSupported() && ctx != null) ContextCompat.getColor(ctx, id) else 0
|
||||||
|
return String.format("#%06X", 0xFFFFFF and clr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,184 @@
|
||||||
|
package GoonXposed.xposed.modules.appearance
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.res.Resources
|
||||||
|
import androidx.core.graphics.toColorInt
|
||||||
|
import de.robv.android.xposed.callbacks.XC_LoadPackage
|
||||||
|
import GoonXposed.xposed.Constants
|
||||||
|
import GoonXposed.xposed.MethodHookBuilder
|
||||||
|
import GoonXposed.xposed.Module
|
||||||
|
import GoonXposed.xposed.Utils.Companion.JSON
|
||||||
|
import GoonXposed.xposed.asFile
|
||||||
|
import GoonXposed.xposed.hook
|
||||||
|
import GoonXposed.xposed.method
|
||||||
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonObjectBuilder
|
||||||
|
import kotlinx.serialization.json.encodeToJsonElement
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Author(
|
||||||
|
val name: String, val id: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ThemeData(
|
||||||
|
val name: String,
|
||||||
|
val description: String? = null,
|
||||||
|
val authors: List<Author>? = null,
|
||||||
|
val spec: Int,
|
||||||
|
val semanticColors: Map<String, List<String>>? = null,
|
||||||
|
val rawColors: Map<String, String>? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Theme(
|
||||||
|
val id: String, val selected: Boolean, val data: ThemeData
|
||||||
|
)
|
||||||
|
|
||||||
|
object ThemesModule : Module() {
|
||||||
|
private lateinit var param: XC_LoadPackage.LoadPackageParam
|
||||||
|
|
||||||
|
private var theme: Theme? = null
|
||||||
|
private val rawColorMap = mutableMapOf<String, Int>()
|
||||||
|
|
||||||
|
private const val THEME_FILE = "current-theme.json"
|
||||||
|
|
||||||
|
@Deprecated("This method is deprecated in the parent class")
|
||||||
|
@ExperimentalSerializationApi
|
||||||
|
override fun buildPayload(builder: JsonObjectBuilder) {
|
||||||
|
builder.apply {
|
||||||
|
put("hasThemeSupport", true)
|
||||||
|
if (theme != null) put("storedTheme", JSON.encodeToJsonElement<Theme>(theme!!))
|
||||||
|
else put("storedTheme", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.fromScreamingSnakeToCamelCase() =
|
||||||
|
this.split("_").joinToString("") { it -> it.lowercase().replaceFirstChar { it.uppercase() } }
|
||||||
|
|
||||||
|
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) {
|
||||||
|
param = packageParam
|
||||||
|
theme = getTheme()
|
||||||
|
hookTheme()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun File.isValidThemeFile(): Boolean {
|
||||||
|
if (!this.exists()) return false
|
||||||
|
|
||||||
|
val text = this.readText()
|
||||||
|
return text.isNotBlank() && text != "{}" && text != "null"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getTheme(): Theme? {
|
||||||
|
val themeFile = File(param.appInfo.dataDir, "${Constants.FILES_DIR}/${THEME_FILE}").apply { asFile() }
|
||||||
|
if (!themeFile.isValidThemeFile()) return null
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val themeText = themeFile.readText()
|
||||||
|
JSON.decodeFromString<Theme>(themeText)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hookTheme() {
|
||||||
|
try {
|
||||||
|
val themeManager = param.classLoader.loadClass("com.discord.theme.utils.ColorUtilsKt")
|
||||||
|
val darkTheme = param.classLoader.loadClass("com.discord.theme.DarkerTheme")
|
||||||
|
val lightTheme = param.classLoader.loadClass("com.discord.theme.LightTheme")
|
||||||
|
|
||||||
|
hookThemeInternal(themeManager, darkTheme, lightTheme)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
GoonXposed.xposed.Utils.Log.e("Failed to hook theme: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hookThemeInternal(
|
||||||
|
themeManager: Class<*>, darkTheme: Class<*>, lightTheme: Class<*>
|
||||||
|
) {
|
||||||
|
val theme = this.theme ?: return
|
||||||
|
|
||||||
|
// Apply rawColors
|
||||||
|
theme.data.rawColors?.forEach { (key, value) ->
|
||||||
|
rawColorMap[key.lowercase()] = hexStringToColorInt(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply semanticColors
|
||||||
|
theme.data.semanticColors?.forEach { (key, value) ->
|
||||||
|
// TEXT_NORMAL -> getTextNormal
|
||||||
|
val methodName = "get${key.fromScreamingSnakeToCamelCase()}"
|
||||||
|
value.forEachIndexed { index, v ->
|
||||||
|
when (index) {
|
||||||
|
0 -> hookThemeMethod(darkTheme, methodName, hexStringToColorInt(v))
|
||||||
|
1 -> hookThemeMethod(lightTheme, methodName, hexStringToColorInt(v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there's any rawColors value, hook the color getter
|
||||||
|
if (!theme.data.rawColors.isNullOrEmpty()) {
|
||||||
|
try {
|
||||||
|
val getColorCompat = themeManager.getDeclaredMethod(
|
||||||
|
"getColorCompat",
|
||||||
|
Resources::class.java,
|
||||||
|
Int::class.javaPrimitiveType,
|
||||||
|
Resources.Theme::class.java,
|
||||||
|
)
|
||||||
|
|
||||||
|
val getColorCompatLegacy = themeManager.getDeclaredMethod(
|
||||||
|
"getColorCompat", Context::class.java, Int::class.javaPrimitiveType
|
||||||
|
)
|
||||||
|
|
||||||
|
val patch = MethodHookBuilder().run {
|
||||||
|
before {
|
||||||
|
try {
|
||||||
|
val arg1 = args[0]
|
||||||
|
val resources = if (arg1 is Context) arg1.resources else (arg1 as Resources)
|
||||||
|
val name = resources.getResourceEntryName(args[1] as Int)
|
||||||
|
|
||||||
|
if (rawColorMap[name] != null) result = rawColorMap[name]
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
build()
|
||||||
|
}
|
||||||
|
|
||||||
|
getColorCompat.hook(patch)
|
||||||
|
getColorCompatLegacy.hook(patch)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
GoonXposed.xposed.Utils.Log.e("Failed to hook getColorCompat: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse HEX colour string to INT. Takes "#RRGGBBAA" or "#RRGGBB"
|
||||||
|
private fun hexStringToColorInt(hexString: String): Int {
|
||||||
|
return try {
|
||||||
|
if (hexString.length == 9) {
|
||||||
|
// Rearrange RRGGBBAA -> AARRGGBB so parseColor() is happy
|
||||||
|
val alpha = hexString.substring(7, 9)
|
||||||
|
val rrggbb = hexString.substring(1, 7)
|
||||||
|
"#$alpha$rrggbb".toColorInt()
|
||||||
|
} else hexString.toColorInt()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hookThemeMethod(themeClass: Class<*>, methodName: String, themeValue: Int) {
|
||||||
|
try {
|
||||||
|
themeClass.getDeclaredMethod(methodName).let { method ->
|
||||||
|
method.hook {
|
||||||
|
before {
|
||||||
|
result = themeValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: NoSuchMethodException) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
package GoonXposed.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 GoonXposed.xposed.Module
|
||||||
|
import GoonXposed.xposed.Utils
|
||||||
|
import GoonXposed.xposed.getAppInfo
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
object AdditionalBridgeMethodsModule : Module() {
|
||||||
|
override fun onContext(context: Context) = with(context) {
|
||||||
|
BridgeModule.registerMethod("Shiggy.fs.getConstants") {
|
||||||
|
mapOf(
|
||||||
|
"data" to dataDir.absolutePath,
|
||||||
|
"files" to filesDir.absolutePath,
|
||||||
|
"cache" to cacheDir.absolutePath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
BridgeModule.registerMethod("Shiggy.fs.delete") {
|
||||||
|
val (path) = it
|
||||||
|
File(path as String).run {
|
||||||
|
if (this.isDirectory) this.deleteRecursively()
|
||||||
|
else this.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BridgeModule.registerMethod("Shiggy.fs.exists") {
|
||||||
|
val (path) = it
|
||||||
|
File(path as String).exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
BridgeModule.registerMethod("Shiggy.fs.read") { it ->
|
||||||
|
val (path) = it
|
||||||
|
val file = File(path as String).apply { openFileGuarded() }
|
||||||
|
|
||||||
|
file.bufferedReader().use { it.readText() }
|
||||||
|
}
|
||||||
|
|
||||||
|
BridgeModule.registerMethod("Shiggy.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("Shiggy.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("GoonCord Error")
|
||||||
|
.setMessage(
|
||||||
|
"""
|
||||||
|
GoonCord: $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("Shiggy.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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
package GoonXposed.xposed.modules.bridge
|
||||||
|
|
||||||
|
import de.robv.android.xposed.callbacks.XC_LoadPackage
|
||||||
|
import GoonXposed.xposed.BuildConfig
|
||||||
|
import GoonXposed.xposed.Constants
|
||||||
|
import GoonXposed.xposed.Module
|
||||||
|
import GoonXposed.xposed.Utils.Log
|
||||||
|
import GoonXposed.xposed.hookMethod
|
||||||
|
import GoonXposed.xposed.method
|
||||||
|
import java.lang.reflect.Method
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See for possible return types:
|
||||||
|
* https://github.com/facebook/react-native/blob/c23e84ae9/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/Arguments.kt#L19
|
||||||
|
*
|
||||||
|
* You may return a [Unit] and the resulting value will be `null`.
|
||||||
|
*/
|
||||||
|
typealias BridgeMethodCallback = (args: BridgeMethodArgs) -> Any?
|
||||||
|
|
||||||
|
typealias BridgeMethodArgs = ArrayList<Any>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A module that exposes a bridge for calling methods from JavaScript.
|
||||||
|
*
|
||||||
|
* To call a method, pass an object with the following structure to a hooked method:
|
||||||
|
* ```js
|
||||||
|
* {
|
||||||
|
* Shiggy: {
|
||||||
|
* method: "method.name",
|
||||||
|
* args: [arg1, arg2, ...]
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* For methods that accept additional arguments, only the first object argument is used for the bridge call.
|
||||||
|
*
|
||||||
|
* The result will be an object with either a `result` or `error` key:
|
||||||
|
* ```js
|
||||||
|
* {
|
||||||
|
* result: ... // The return value of the method
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
* or
|
||||||
|
* ```js
|
||||||
|
* {
|
||||||
|
* error: "Error message"
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
object BridgeModule : Module() {
|
||||||
|
private lateinit var readableMapGetString: Method
|
||||||
|
private lateinit var readableMapToHashMap: Method
|
||||||
|
private lateinit var argumentsMakeNative: Method
|
||||||
|
|
||||||
|
private const val CALL_DATA_KEY = "Shiggy"
|
||||||
|
private const val METHOD_NAME_KEY = "method"
|
||||||
|
private const val METHOD_ARGS_KEY = "args"
|
||||||
|
|
||||||
|
private val methods: MutableMap<String, BridgeMethodCallback> = mutableMapOf()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a bridge method that can be called from JavaScript.
|
||||||
|
*
|
||||||
|
* If the method is already registered, it will be overwritten.
|
||||||
|
*
|
||||||
|
* @param name The name of the method to register.
|
||||||
|
* @param callback The callback to invoke when the method is called.
|
||||||
|
*/
|
||||||
|
fun registerMethod(name: String, callback: BridgeMethodCallback) {
|
||||||
|
if (methods.containsKey(name)) Log.w("Bridge method already exists and will be overridden: $name")
|
||||||
|
methods[name] = callback
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
|
||||||
|
val arguments = classLoader.loadClass("com.facebook.react.bridge.Arguments")
|
||||||
|
val readableMap = classLoader.loadClass("com.facebook.react.bridge.ReadableMap")
|
||||||
|
val promise = classLoader.loadClass("com.facebook.react.bridge.Promise")
|
||||||
|
|
||||||
|
val promiseResolve = promise.method("resolve", Object::class.java)
|
||||||
|
argumentsMakeNative = arguments.method("makeNativeObject", Object::class.java)
|
||||||
|
readableMapGetString = readableMap.method("getString", String::class.java)
|
||||||
|
readableMapToHashMap = readableMap.method("toHashMap")
|
||||||
|
|
||||||
|
classLoader.loadClass("com.horcrux.svg.RNSVGRenderableManager")
|
||||||
|
.hookMethod("getBBox", Double::class.javaObjectType, readableMap) {
|
||||||
|
before {
|
||||||
|
callBridgeMethod(readableMapToHashMap(args[1]!!))?.let { result = it.toNativeObject() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
classLoader.loadClass("com.facebook.react.modules.blob.FileReaderModule")
|
||||||
|
.hookMethod("readAsDataURL", readableMap, promise) {
|
||||||
|
before {
|
||||||
|
val (readableMap, promise) = args
|
||||||
|
callBridgeMethod(readableMapToHashMap(readableMap!!))?.let {
|
||||||
|
promiseResolve.invoke(promise, it.toNativeObject())
|
||||||
|
result = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear methods every package load. Plugins should re-register on each load.
|
||||||
|
methods.clear()
|
||||||
|
registerDefaultMethods()
|
||||||
|
|
||||||
|
return@with
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerDefaultMethods() {
|
||||||
|
methods["Shiggy.info"] = {
|
||||||
|
mapOf(
|
||||||
|
"name" to Constants.LOADER_NAME, "version" to BuildConfig.VERSION_CODE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
methods["Shiggy.test"] = {
|
||||||
|
mapOf(
|
||||||
|
"string" to "string",
|
||||||
|
"number" to 7256,
|
||||||
|
"array" to listOf("testing", 527737, listOf(true)),
|
||||||
|
"object" to mapOf("nested" to true),
|
||||||
|
"boolean" to false,
|
||||||
|
"args" to it,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Any?.toNativeObject(): Any? = argumentsMakeNative.invoke(
|
||||||
|
null, when (this) {
|
||||||
|
Unit -> null
|
||||||
|
else -> this
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun readableMapToHashMap(map: Any): HashMap<String, Any?> {
|
||||||
|
@Suppress("UNCHECKED_CAST") return readableMapToHashMap.invoke(map) as HashMap<String, Any?>
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun callBridgeMethod(hashMap: HashMap<String, Any?>): Map<String, Any?>? = try {
|
||||||
|
val (method, args) = getBridgeCallData(hashMap) ?: return null
|
||||||
|
val ret = method(args).toNativeObject()
|
||||||
|
mapOf("result" to ret)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
mapOf("error" to e.stackTraceToString())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getBridgeCallData(hashMap: HashMap<String, Any?>): Pair<BridgeMethodCallback, BridgeMethodArgs>? {
|
||||||
|
@Suppress("UNCHECKED_CAST") val data = hashMap[CALL_DATA_KEY] as HashMap<String, Any?>?
|
||||||
|
data ?: return null
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST") val name = data[METHOD_NAME_KEY] as String
|
||||||
|
val method = methods[name]
|
||||||
|
method ?: throw Error("Method not registered: $name")
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST") val args = data[METHOD_ARGS_KEY] as BridgeMethodArgs
|
||||||
|
|
||||||
|
return Pair(method, args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
package GoonXposed.xposed.modules.no_track
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import de.robv.android.xposed.callbacks.XC_LoadPackage
|
||||||
|
import GoonXposed.xposed.Module
|
||||||
|
import GoonXposed.xposed.Utils.Log
|
||||||
|
import GoonXposed.xposed.hookMethod
|
||||||
|
import GoonXposed.xposed.safeLoadClass
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hooks Discord's crash reporting to disable Sentry initialization.
|
||||||
|
* While Discord doesn't have Sentry auto-initialization on by default, we still hook the content provider just to be safe.
|
||||||
|
*/
|
||||||
|
object BlockCrashReportingModule : Module() {
|
||||||
|
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
|
||||||
|
val crashReportingClass = classLoader.safeLoadClass("com.discord.crash_reporting.CrashReporting")
|
||||||
|
crashReportingClass?.apply {
|
||||||
|
|
||||||
|
// This only exists on 30720x and above
|
||||||
|
runCatching {
|
||||||
|
hookMethod("isDisabled") {
|
||||||
|
before {
|
||||||
|
Log.i("Forced CrashReporting.isDisabled() to true")
|
||||||
|
result = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure {
|
||||||
|
// In older versions, this hook works fine.
|
||||||
|
// Hooking this on 30720x will result in a crash after a few seconds,
|
||||||
|
// since Discord asserts initialization when setting a Sentry tag.
|
||||||
|
hookMethod(
|
||||||
|
"init", Context::class.java, String::class.java
|
||||||
|
) {
|
||||||
|
before {
|
||||||
|
Log.i("Blocked CrashReporting initialization")
|
||||||
|
result = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val sentryInitProviderClass = classLoader.safeLoadClass("io.sentry.android.core.SentryInitProvider")
|
||||||
|
sentryInitProviderClass?.hookMethod("onCreate") {
|
||||||
|
before {
|
||||||
|
Log.i("Blocked SentryInitProvider initialization")
|
||||||
|
result = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return@with
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
package GoonXposed.xposed.modules.no_track
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import de.robv.android.xposed.callbacks.XC_LoadPackage
|
||||||
|
import GoonXposed.xposed.Module
|
||||||
|
import GoonXposed.xposed.Utils.Log
|
||||||
|
import GoonXposed.xposed.hookMethod
|
||||||
|
import GoonXposed.xposed.safeLoadClass
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hooks Discord's deep links tracking to disable AppsFlyer initialization.
|
||||||
|
*/
|
||||||
|
object BlockDeepLinksTrackingModule : Module() {
|
||||||
|
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
|
||||||
|
val deepLinksClass = classLoader.safeLoadClass("com.discord.deep_link.DeepLinks")
|
||||||
|
deepLinksClass?.hookMethod(
|
||||||
|
"init", Context::class.java
|
||||||
|
) {
|
||||||
|
before {
|
||||||
|
Log.i("Blocked DeepLinks tracking initialization")
|
||||||
|
result = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return@with
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
package GoonXposed.xposed
|
|
||||||
|
|
||||||
import GoonXposed.xposed.modules.*
|
|
||||||
import GoonXposed.xposed.module.appearance.ThemesModule
|
|
||||||
import GoonXposed.xposed.modules.appearance.FontsModule
|
|
||||||
import GoonXposed.xposed.modules.appearance.SysColorsModule
|
|
||||||
import GoonXposed.xposed.modules.bridge.AdditionalBridgeMethodsModule
|
|
||||||
import GoonXposed.xposed.modules.bridge.BridgeModule
|
|
||||||
import android.app.Activity
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.ContextWrapper
|
|
||||||
import android.os.Bundle
|
|
||||||
import de.robv.android.xposed.IXposedHookLoadPackage
|
|
||||||
import de.robv.android.xposed.IXposedHookZygoteInit
|
|
||||||
import de.robv.android.xposed.callbacks.XC_LoadPackage
|
|
||||||
|
|
||||||
class Main : Module(), IXposedHookLoadPackage, IXposedHookZygoteInit {
|
|
||||||
private var hooked = false
|
|
||||||
private val modules =
|
|
||||||
mutableListOf(
|
|
||||||
HookScriptLoaderModule(),
|
|
||||||
BridgeModule(),
|
|
||||||
AdditionalBridgeMethodsModule(),
|
|
||||||
UpdaterModule(),
|
|
||||||
FixResourcesModule(),
|
|
||||||
LogBoxModule(),
|
|
||||||
FontsModule(),
|
|
||||||
SysColorsModule(),
|
|
||||||
ThemesModule(),
|
|
||||||
)
|
|
||||||
|
|
||||||
init {
|
|
||||||
modules.add(PayloadGlobalModule(modules))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun initZygote(startupParam: IXposedHookZygoteInit.StartupParam) {
|
|
||||||
for (module in modules) module.onInit(startupParam)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun handleLoadPackage(param: XC_LoadPackage.LoadPackageParam) =
|
|
||||||
with(param) {
|
|
||||||
if (hooked) return
|
|
||||||
|
|
||||||
val reactActivity = classLoader.loadClass(Constants.TARGET_ACTIVITY)
|
|
||||||
|
|
||||||
ContextWrapper::class.java.hookMethod("attachBaseContext", Context::class.java) {
|
|
||||||
after { this@Main.onContext(args[0] as Context) }
|
|
||||||
}
|
|
||||||
|
|
||||||
reactActivity.hookMethod("onCreate", Bundle::class.java) {
|
|
||||||
after { this@Main.onActivity(thisObject as Activity) }
|
|
||||||
}
|
|
||||||
|
|
||||||
this@Main.onLoad(param)
|
|
||||||
|
|
||||||
hooked = true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) {
|
|
||||||
for (module in modules) module.onLoad(packageParam)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onContext(context: Context) {
|
|
||||||
for (module in modules) module.onContext(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onActivity(activity: Activity) {
|
|
||||||
for (module in modules) module.onActivity(activity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,192 +0,0 @@
|
||||||
package GoonXposed.xposed
|
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Build
|
|
||||||
import de.robv.android.xposed.IXposedHookZygoteInit
|
|
||||||
import de.robv.android.xposed.XC_MethodHook
|
|
||||||
import de.robv.android.xposed.XposedBridge
|
|
||||||
import de.robv.android.xposed.callbacks.XC_LoadPackage
|
|
||||||
import java.io.File
|
|
||||||
import java.lang.reflect.Method
|
|
||||||
import kotlinx.serialization.json.JsonObjectBuilder
|
|
||||||
|
|
||||||
data class AppInfo(
|
|
||||||
val name: String,
|
|
||||||
val packageName: String,
|
|
||||||
val version: String,
|
|
||||||
val versionCode: Long,
|
|
||||||
)
|
|
||||||
|
|
||||||
abstract class Module {
|
|
||||||
/** Builds a JSON payload to be injected into the JavaScript context. */
|
|
||||||
@Deprecated(
|
|
||||||
"This will be removed in future versions. Payloads can be replaced via synchronous bridge methods."
|
|
||||||
)
|
|
||||||
open fun buildPayload(builder: JsonObjectBuilder) {}
|
|
||||||
|
|
||||||
/** Called during Zygote initialization. */
|
|
||||||
open fun onInit(startupParam: IXposedHookZygoteInit.StartupParam) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when a package is loaded.
|
|
||||||
*
|
|
||||||
* Hooks should typically be set up here.
|
|
||||||
*
|
|
||||||
* Bridge methods that do not require [Context] can also be registered here.
|
|
||||||
*/
|
|
||||||
open fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called after [Context] has been attached to a [android.content.ContextWrapper] via
|
|
||||||
* [android.content.ContextWrapper.attachBaseContext].
|
|
||||||
*
|
|
||||||
* This may be called multiple times for different [Context]s, e.g. when the app is killed by
|
|
||||||
* the system.
|
|
||||||
*
|
|
||||||
* For bridge methods that require access to [Context], register them here.
|
|
||||||
*/
|
|
||||||
open fun onContext(context: Context) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called after an [Activity.onCreate] method is executed.
|
|
||||||
*
|
|
||||||
* UI code that requires an [Activity] context can be placed here.
|
|
||||||
*
|
|
||||||
* Bridge methods must not be registered here, as it can be too late and cause crashes.
|
|
||||||
*
|
|
||||||
* If you need to register bridge methods that require an [Activity], do it in [onContext] and
|
|
||||||
* make it async.
|
|
||||||
*
|
|
||||||
* Once an [Activity] is available, execute the queued calls. Then from JS, call the method
|
|
||||||
* asynchronously.
|
|
||||||
*/
|
|
||||||
open fun onActivity(activity: Activity) {}
|
|
||||||
|
|
||||||
protected fun Context.getAppInfo(): AppInfo {
|
|
||||||
val pInfo = packageManager.getPackageInfo(packageName, 0)
|
|
||||||
val versionCode =
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) pInfo.longVersionCode
|
|
||||||
else @Suppress("DEPRECATION") pInfo.versionCode.toLong()
|
|
||||||
|
|
||||||
return AppInfo(
|
|
||||||
packageManager.getApplicationLabel(applicationInfo).toString(),
|
|
||||||
packageName,
|
|
||||||
pInfo.versionName ?: versionCode.toString(),
|
|
||||||
versionCode,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
protected fun File.asDir() {
|
|
||||||
if (!this.isDirectory) this.delete()
|
|
||||||
this.mkdirs()
|
|
||||||
}
|
|
||||||
|
|
||||||
protected fun File.asFile() {
|
|
||||||
if (!this.isFile) this.deleteRecursively()
|
|
||||||
}
|
|
||||||
|
|
||||||
protected fun Class<*>.method(name: String, vararg params: Class<*>?): Method =
|
|
||||||
getDeclaredMethod(name, *params).apply { isAccessible = true }
|
|
||||||
|
|
||||||
protected fun ClassLoader.safeLoadClass(name: String): Class<*>? =
|
|
||||||
runCatching { loadClass(name) }.getOrNull()
|
|
||||||
|
|
||||||
protected fun Method.hook(hook: XC_MethodHook): XC_MethodHook.Unhook =
|
|
||||||
XposedBridge.hookMethod(this, hook)
|
|
||||||
|
|
||||||
protected fun Method.hook(block: MethodHookBuilder.() -> Unit): XC_MethodHook.Unhook =
|
|
||||||
hook(MethodHookBuilder().apply(block).build())
|
|
||||||
|
|
||||||
protected fun Class<*>.hookMethod(
|
|
||||||
name: String,
|
|
||||||
vararg params: Class<*>?,
|
|
||||||
block: MethodHookBuilder.() -> Unit
|
|
||||||
): XC_MethodHook.Unhook = method(name, *params).hook(MethodHookBuilder().apply(block).build())
|
|
||||||
|
|
||||||
protected class MethodHookBuilder {
|
|
||||||
private var beforeBlock: (HookScope.() -> Unit)? = null
|
|
||||||
private var afterBlock: (HookScope.() -> Unit)? = null
|
|
||||||
|
|
||||||
fun before(block: HookScope.() -> Unit) {
|
|
||||||
beforeBlock = block
|
|
||||||
}
|
|
||||||
|
|
||||||
fun after(block: HookScope.() -> Unit) {
|
|
||||||
afterBlock = block
|
|
||||||
}
|
|
||||||
|
|
||||||
fun build(): XC_MethodHook =
|
|
||||||
object : XC_MethodHook() {
|
|
||||||
override fun beforeHookedMethod(param: MethodHookParam) {
|
|
||||||
val b = beforeBlock
|
|
||||||
if (b != null) {
|
|
||||||
val scope =
|
|
||||||
HookScope(
|
|
||||||
param = param,
|
|
||||||
proceed = { p -> super.beforeHookedMethod(p) }
|
|
||||||
)
|
|
||||||
scope.b()
|
|
||||||
} else {
|
|
||||||
super.beforeHookedMethod(param)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun afterHookedMethod(param: MethodHookParam) {
|
|
||||||
val a = afterBlock
|
|
||||||
if (a != null) {
|
|
||||||
val scope =
|
|
||||||
HookScope(
|
|
||||||
param = param,
|
|
||||||
proceed = { p -> super.afterHookedMethod(p) }
|
|
||||||
)
|
|
||||||
scope.a()
|
|
||||||
} else {
|
|
||||||
super.afterHookedMethod(param)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scope object passed to before/after hook blocks.
|
|
||||||
*
|
|
||||||
* Provides:
|
|
||||||
* - Access to the [param] object
|
|
||||||
* - [proceed] to call the original XC_MethodHook super method
|
|
||||||
* - Accessors for `thisObject`, `args`, `result`, and `throwable`
|
|
||||||
*
|
|
||||||
* @property param The [XC_MethodHook.MethodHookParam] for the current hook.
|
|
||||||
* @property proceed Function that calls the super method.
|
|
||||||
*/
|
|
||||||
protected class HookScope
|
|
||||||
internal constructor(
|
|
||||||
val param: XC_MethodHook.MethodHookParam,
|
|
||||||
private val proceed: (XC_MethodHook.MethodHookParam) -> Unit
|
|
||||||
) {
|
|
||||||
/**
|
|
||||||
* Continues with the default XC_MethodHook super behavior. Equivalent to calling
|
|
||||||
* `super.beforeHookedMethod(param)` or `super.afterHookedMethod(param)` depending on the
|
|
||||||
* phase.
|
|
||||||
*/
|
|
||||||
fun proceed() = proceed(param)
|
|
||||||
|
|
||||||
val thisObject: Any?
|
|
||||||
get() = param.thisObject
|
|
||||||
|
|
||||||
val args: Array<Any?>
|
|
||||||
get() = param.args
|
|
||||||
|
|
||||||
var result: Any?
|
|
||||||
get() = param.result
|
|
||||||
set(value) {
|
|
||||||
param.result = value
|
|
||||||
}
|
|
||||||
|
|
||||||
var throwable: Throwable?
|
|
||||||
get() = param.throwable
|
|
||||||
set(value) {
|
|
||||||
param.throwable = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
package GoonXposed.xposed
|
|
||||||
|
|
||||||
import android.app.AndroidAppHelper
|
|
||||||
import android.content.Intent
|
|
||||||
import kotlin.system.exitProcess
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
|
|
||||||
class Utils {
|
|
||||||
companion object {
|
|
||||||
val JSON = Json { ignoreUnknownKeys = true }
|
|
||||||
|
|
||||||
fun reloadApp() {
|
|
||||||
val application = AndroidAppHelper.currentApplication()
|
|
||||||
val intent =
|
|
||||||
application.packageManager.getLaunchIntentForPackage(application.packageName)
|
|
||||||
application.startActivity(Intent.makeRestartActivityTask(intent!!.component))
|
|
||||||
exitProcess(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
object Log {
|
|
||||||
fun e(msg: String) = android.util.Log.e(Constants.LOG_TAG, msg)
|
|
||||||
fun e(msg: String, throwable: Throwable) =
|
|
||||||
android.util.Log.e(Constants.LOG_TAG, msg, throwable)
|
|
||||||
fun i(msg: String) = android.util.Log.i(Constants.LOG_TAG, msg)
|
|
||||||
fun i(msg: String, throwable: Throwable) =
|
|
||||||
android.util.Log.i(Constants.LOG_TAG, msg, throwable)
|
|
||||||
fun w(msg: String) = android.util.Log.w(Constants.LOG_TAG, msg)
|
|
||||||
fun w(msg: String, throwable: Throwable) =
|
|
||||||
android.util.Log.w(Constants.LOG_TAG, msg, throwable)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
package GoonXposed.xposed.modules
|
|
||||||
|
|
||||||
import GoonXposed.xposed.Constants.Companion.TARGET_PACKAGE
|
|
||||||
import GoonXposed.xposed.Module
|
|
||||||
import android.content.res.Resources
|
|
||||||
import de.robv.android.xposed.callbacks.XC_LoadPackage
|
|
||||||
|
|
||||||
/** Hooks [Resources.getIdentifier] to fix resource package name mismatch. */
|
|
||||||
class FixResourcesModule : Module() {
|
|
||||||
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) =
|
|
||||||
with(packageParam) {
|
|
||||||
if (packageName != TARGET_PACKAGE) {
|
|
||||||
Resources::class.java.hookMethod(
|
|
||||||
"getIdentifier",
|
|
||||||
String::class.java,
|
|
||||||
String::class.java,
|
|
||||||
String::class.java
|
|
||||||
) { before { if (args[2] == packageName) args[2] = TARGET_PACKAGE } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
package GoonXposed.xposed.modules
|
|
||||||
|
|
||||||
import GoonXposed.xposed.Constants
|
|
||||||
import GoonXposed.xposed.Module
|
|
||||||
import GoonXposed.xposed.Utils.Log
|
|
||||||
import GoonXposed.xposed.modules.HookScriptLoaderModule.Companion.PRELOADS_DIR
|
|
||||||
import android.content.res.AssetManager
|
|
||||||
import android.content.res.XModuleResources
|
|
||||||
import de.robv.android.xposed.IXposedHookZygoteInit
|
|
||||||
import de.robv.android.xposed.XposedBridge
|
|
||||||
import de.robv.android.xposed.callbacks.XC_LoadPackage
|
|
||||||
import java.io.File
|
|
||||||
import java.lang.reflect.Method
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hooks React Native's script loading methods to load custom scripts and bundles.
|
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
class HookScriptLoaderModule : Module() {
|
|
||||||
private lateinit var preloadsDir: File
|
|
||||||
private lateinit var mainScript: File
|
|
||||||
|
|
||||||
private lateinit var modulePath: String
|
|
||||||
private lateinit var resources: XModuleResources
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val PRELOADS_DIR = "preloads"
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onInit(startupParam: IXposedHookZygoteInit.StartupParam) {
|
|
||||||
this@HookScriptLoaderModule.modulePath = startupParam.modulePath
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) =
|
|
||||||
with(packageParam) {
|
|
||||||
val cacheDir = File(appInfo.dataDir, Constants.CACHE_DIR).apply { asDir() }
|
|
||||||
val filesDir = File(appInfo.dataDir, Constants.FILES_DIR).apply { asDir() }
|
|
||||||
|
|
||||||
preloadsDir = File(filesDir, PRELOADS_DIR).apply { asDir() }
|
|
||||||
mainScript = File(cacheDir, Constants.MAIN_SCRIPT_FILE).apply { asFile() }
|
|
||||||
|
|
||||||
listOf(
|
|
||||||
"com.facebook.react.runtime.ReactInstance$1",
|
|
||||||
// TODO: Remove once Discord fully switches to Bridgeless
|
|
||||||
"com.facebook.react.bridge.CatalystInstanceImpl"
|
|
||||||
)
|
|
||||||
.mapNotNull { classLoader.safeLoadClass(it) }
|
|
||||||
.forEach { hook(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hook(instance: Class<*>) {
|
|
||||||
val loadScriptFromAssets =
|
|
||||||
instance.method(
|
|
||||||
"loadScriptFromAssets",
|
|
||||||
AssetManager::class.java,
|
|
||||||
String::class.java,
|
|
||||||
Boolean::class.javaPrimitiveType
|
|
||||||
)
|
|
||||||
|
|
||||||
val loadScriptFromFile =
|
|
||||||
instance.method(
|
|
||||||
"loadScriptFromFile",
|
|
||||||
String::class.java,
|
|
||||||
String::class.java,
|
|
||||||
Boolean::class.javaPrimitiveType
|
|
||||||
)
|
|
||||||
|
|
||||||
loadScriptFromAssets.hook {
|
|
||||||
before {
|
|
||||||
Log.i("Received call to loadScriptFromAssets: ${args[1]} (sync: ${args[2]})")
|
|
||||||
runCustomScripts(loadScriptFromFile, loadScriptFromAssets)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadScriptFromFile.hook {
|
|
||||||
before {
|
|
||||||
Log.i("Received call to loadScriptFromFile: ${args[0]} (sync: ${args[2]})")
|
|
||||||
runCustomScripts(loadScriptFromFile, loadScriptFromAssets)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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() }
|
|
||||||
|
|
||||||
val loadSynchronously = args[2]
|
|
||||||
val runScriptFile = { file: File ->
|
|
||||||
Log.i("Loading script: ${file.absolutePath}")
|
|
||||||
|
|
||||||
XposedBridge.invokeOriginalMethod(
|
|
||||||
loadScriptFromFile,
|
|
||||||
thisObject,
|
|
||||||
arrayOf(file.absolutePath, file.absolutePath, loadSynchronously)
|
|
||||||
)
|
|
||||||
|
|
||||||
Unit
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
|
|
||||||
preloadsDir.walk().filter { it.isFile }.forEach(runScriptFile)
|
|
||||||
|
|
||||||
if (mainScript.exists()) runScriptFile(mainScript)
|
|
||||||
else {
|
|
||||||
Log.i("Main script does not exist, falling back")
|
|
||||||
|
|
||||||
if (!::resources.isInitialized)
|
|
||||||
resources = XModuleResources.createInstance(modulePath, null)
|
|
||||||
|
|
||||||
XposedBridge.invokeOriginalMethod(
|
|
||||||
loadScriptFromAssets,
|
|
||||||
thisObject,
|
|
||||||
arrayOf(resources.assets, "assets://bundle.js", loadSynchronously)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Log.e("Unable to run scripts:", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,510 +0,0 @@
|
||||||
package GoonXposed.xposed.modules
|
|
||||||
|
|
||||||
import GoonXposed.xposed.Constants
|
|
||||||
import GoonXposed.xposed.Module
|
|
||||||
import GoonXposed.xposed.Utils
|
|
||||||
import GoonXposed.xposed.Utils.Companion.reloadApp
|
|
||||||
import GoonXposed.xposed.Utils.Log
|
|
||||||
import android.app.AlertDialog
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import android.view.View
|
|
||||||
import android.widget.Button
|
|
||||||
import android.widget.EditText
|
|
||||||
import android.widget.LinearLayout
|
|
||||||
import android.widget.Switch
|
|
||||||
import android.widget.TextView
|
|
||||||
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 java.io.File
|
|
||||||
import kotlinx.serialization.json.*
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replacement for the old recovery/dev dialog. Shows a dark-themed dialog on the main thread with:
|
|
||||||
* - Load from custom URL toggle + URL input (persists to files/pyoncord/loader.json)
|
|
||||||
* - Save & Reload, Delete bundle, Close actions
|
|
||||||
*
|
|
||||||
* This simplified version removes the Safe Mode toggle and the per-addon list. Save only persists
|
|
||||||
* the custom URL configuration.
|
|
||||||
*/
|
|
||||||
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
|
|
||||||
|
|
||||||
try {
|
|
||||||
val dcdReactNativeHostClass =
|
|
||||||
classLoader.loadClass("com.discord.bridge.DCDReactNativeHost")
|
|
||||||
bridgeDevSupportManagerClass =
|
|
||||||
classLoader.loadClass(
|
|
||||||
"com.facebook.react.devsupport.BridgeDevSupportManager"
|
|
||||||
)
|
|
||||||
|
|
||||||
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"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Enable developer support (so shake/dev menu becomes available)
|
|
||||||
XposedBridge.hookMethod(
|
|
||||||
getUseDeveloperSupportMethod,
|
|
||||||
object : XC_MethodHook() {
|
|
||||||
override fun beforeHookedMethod(param: MethodHookParam) {
|
|
||||||
param.result = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Replace reload with app restart to avoid remote dev server dependency
|
|
||||||
XposedBridge.hookMethod(
|
|
||||||
handleReloadJSMethod,
|
|
||||||
object : XC_MethodHook() {
|
|
||||||
override fun beforeHookedMethod(param: MethodHookParam) {
|
|
||||||
reloadApp()
|
|
||||||
param.result = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Intercept showDevOptionsDialog and replace with our dialog
|
|
||||||
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.message}",
|
|
||||||
ex
|
|
||||||
)
|
|
||||||
XposedBridge.log(
|
|
||||||
"GoonXposed: Failed to show dev options dialog: ${android.util.Log.getStackTraceString(ex)}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prevent original menu from showing
|
|
||||||
param.result = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("Error in LogBoxModule.onLoad: ${e.message}", e)
|
|
||||||
XposedBridge.log(
|
|
||||||
"GoonXposed: LogBoxModule.onLoad error: ${android.util.Log.getStackTraceString(e)}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return@with
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showRecoveryAlert(context: Context) {
|
|
||||||
// Always perform UI operations on the main thread to avoid runtime exceptions
|
|
||||||
Handler(Looper.getMainLooper()).post {
|
|
||||||
try {
|
|
||||||
val themed = context
|
|
||||||
|
|
||||||
// Prepare paths
|
|
||||||
val filesDir =
|
|
||||||
File(packageParam.appInfo.dataDir, Constants.FILES_DIR).apply { mkdirs() }
|
|
||||||
val configFile = File(filesDir, "loader.json")
|
|
||||||
val preloadsDir =
|
|
||||||
File(filesDir, HookScriptLoaderModule.PRELOADS_DIR).apply { mkdirs() }
|
|
||||||
|
|
||||||
// Read existing loader.json (guarded) for custom URL
|
|
||||||
var customEnabled = false
|
|
||||||
var customUrl = ""
|
|
||||||
try {
|
|
||||||
if (configFile.exists()) {
|
|
||||||
val je = Utils.JSON.parseToJsonElement(configFile.readText())
|
|
||||||
val custom = je.jsonObject["customLoadUrl"]?.jsonObject
|
|
||||||
if (custom != null) {
|
|
||||||
customEnabled = custom["enabled"]?.jsonPrimitive?.booleanOrNull ?: false
|
|
||||||
customUrl = custom["url"]?.jsonPrimitive?.contentOrNull ?: ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
Log.e("Failed to parse loader.json: ${t.message}", t)
|
|
||||||
XposedBridge.log(
|
|
||||||
"ShiggyXposed: loader.json parse failed: ${android.util.Log.getStackTraceString(t)}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the dialog UI programmatically.
|
|
||||||
val padding = (themed.resources.displayMetrics.density * 16).toInt()
|
|
||||||
val container =
|
|
||||||
LinearLayout(themed).apply {
|
|
||||||
orientation = LinearLayout.VERTICAL
|
|
||||||
setPadding(padding, padding, padding, padding)
|
|
||||||
setBackgroundColor(0xFF121212.toInt())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to create a labeled switch row
|
|
||||||
fun addSwitchRow(labelText: String, checked: Boolean): Pair<TextView, Switch> {
|
|
||||||
val rowLp =
|
|
||||||
LinearLayout.LayoutParams(
|
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
|
||||||
)
|
|
||||||
val row =
|
|
||||||
LinearLayout(themed).apply {
|
|
||||||
orientation = LinearLayout.HORIZONTAL
|
|
||||||
layoutParams = rowLp
|
|
||||||
}
|
|
||||||
|
|
||||||
val tvLp =
|
|
||||||
LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
|
||||||
val tv =
|
|
||||||
TextView(themed).apply {
|
|
||||||
text = labelText
|
|
||||||
setTextColor(0xFFFFFFFF.toInt())
|
|
||||||
textSize = 15f
|
|
||||||
layoutParams = tvLp
|
|
||||||
}
|
|
||||||
|
|
||||||
val swLp =
|
|
||||||
LinearLayout.LayoutParams(
|
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
|
||||||
)
|
|
||||||
val sw =
|
|
||||||
Switch(themed).apply {
|
|
||||||
isChecked = checked
|
|
||||||
layoutParams = swLp
|
|
||||||
}
|
|
||||||
|
|
||||||
row.addView(tv)
|
|
||||||
row.addView(sw)
|
|
||||||
container.addView(row)
|
|
||||||
return Pair(tv, sw)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create rows: custom URL toggle
|
|
||||||
val customRow = addSwitchRow("Load from custom URL", false)
|
|
||||||
val customSwitch = customRow.second
|
|
||||||
|
|
||||||
// Confirmation modal helper (keeps previous messaging/behavior)
|
|
||||||
fun showReloadConfirmModal(parentCtx: Context, turningOn: Boolean) {
|
|
||||||
try {
|
|
||||||
val dialogContainer =
|
|
||||||
LinearLayout(parentCtx).apply {
|
|
||||||
orientation = LinearLayout.VERTICAL
|
|
||||||
val pad =
|
|
||||||
(parentCtx.resources.displayMetrics.density * 16)
|
|
||||||
.toInt()
|
|
||||||
setPadding(pad, pad, pad, pad)
|
|
||||||
setBackgroundColor(0xFF121212.toInt())
|
|
||||||
}
|
|
||||||
val message =
|
|
||||||
TextView(parentCtx).apply {
|
|
||||||
text =
|
|
||||||
if (turningOn) {
|
|
||||||
"All add-ons will be temporarily disabled upon reload."
|
|
||||||
} else {
|
|
||||||
"All add-ons will load normally."
|
|
||||||
}
|
|
||||||
setTextColor(0xFFFFFFFF.toInt())
|
|
||||||
textSize = 14f
|
|
||||||
setPadding(0, 0, 0, 8)
|
|
||||||
}
|
|
||||||
dialogContainer.addView(message)
|
|
||||||
|
|
||||||
val buttonsRow =
|
|
||||||
LinearLayout(parentCtx).apply {
|
|
||||||
orientation = LinearLayout.HORIZONTAL
|
|
||||||
val lp =
|
|
||||||
LinearLayout.LayoutParams(
|
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
|
||||||
)
|
|
||||||
layoutParams = lp
|
|
||||||
}
|
|
||||||
|
|
||||||
val reloadNowBtn =
|
|
||||||
Button(parentCtx).apply {
|
|
||||||
text = "Reload Now"
|
|
||||||
setTextColor(0xFF000000.toInt())
|
|
||||||
setBackgroundColor(0xFFBB86FC.toInt())
|
|
||||||
val wlp =
|
|
||||||
LinearLayout.LayoutParams(
|
|
||||||
0,
|
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
|
||||||
1f
|
|
||||||
)
|
|
||||||
wlp.marginEnd = 8
|
|
||||||
layoutParams = wlp
|
|
||||||
setOnClickListener {
|
|
||||||
try {
|
|
||||||
reloadApp()
|
|
||||||
} catch (_: Throwable) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val laterBtn =
|
|
||||||
Button(parentCtx).apply {
|
|
||||||
text = "Later"
|
|
||||||
setTextColor(0xFFFFFFFF.toInt())
|
|
||||||
val wlp =
|
|
||||||
LinearLayout.LayoutParams(
|
|
||||||
0,
|
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
|
||||||
1f
|
|
||||||
)
|
|
||||||
layoutParams = wlp
|
|
||||||
}
|
|
||||||
|
|
||||||
buttonsRow.addView(
|
|
||||||
reloadNowBtn,
|
|
||||||
LinearLayout.LayoutParams(
|
|
||||||
0,
|
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
|
||||||
1f
|
|
||||||
)
|
|
||||||
.apply { marginEnd = 8 }
|
|
||||||
)
|
|
||||||
buttonsRow.addView(
|
|
||||||
laterBtn,
|
|
||||||
LinearLayout.LayoutParams(
|
|
||||||
0,
|
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
|
||||||
1f
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
dialogContainer.addView(buttonsRow)
|
|
||||||
|
|
||||||
val dlg = AlertDialog.Builder(parentCtx).setView(dialogContainer).create()
|
|
||||||
laterBtn.setOnClickListener { dlg.dismiss() }
|
|
||||||
dlg.show()
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
Log.e("Failed to show reload confirm modal: ${t.message}", t)
|
|
||||||
XposedBridge.log(
|
|
||||||
"GoonXposed: Reload confirm modal failed: ${android.util.Log.getStackTraceString(t)}"
|
|
||||||
)
|
|
||||||
try {
|
|
||||||
AlertDialog.Builder(parentCtx)
|
|
||||||
.setTitle("Reload now?")
|
|
||||||
.setMessage(
|
|
||||||
if (turningOn)
|
|
||||||
"All add-ons will be temporarily disabled upon reload."
|
|
||||||
else "All add-ons will load normally."
|
|
||||||
)
|
|
||||||
.setPositiveButton("Reload Now") { _, _ -> reloadApp() }
|
|
||||||
.setNegativeButton("Later", null)
|
|
||||||
.show()
|
|
||||||
} catch (_: Throwable) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Safe mode toggle removed; no-op.
|
|
||||||
|
|
||||||
// URL label + input
|
|
||||||
val lblUrl =
|
|
||||||
TextView(themed).apply {
|
|
||||||
text = "Custom bundle URL"
|
|
||||||
setTextColor(0xFFFFFFFF.toInt())
|
|
||||||
textSize = 13f
|
|
||||||
val lp =
|
|
||||||
LinearLayout.LayoutParams(
|
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
|
||||||
)
|
|
||||||
lp.topMargin = (themed.resources.displayMetrics.density * 8).toInt()
|
|
||||||
layoutParams = lp
|
|
||||||
}
|
|
||||||
val urlInput =
|
|
||||||
EditText(themed).apply {
|
|
||||||
hint = "https://example.com/bundle.js"
|
|
||||||
setTextColor(0xFFFFFFFF.toInt())
|
|
||||||
setHintTextColor(0xFFBDBDBD.toInt())
|
|
||||||
setBackgroundColor(0xFF1E1E1E.toInt())
|
|
||||||
val lp =
|
|
||||||
LinearLayout.LayoutParams(
|
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
|
||||||
)
|
|
||||||
lp.topMargin = (themed.resources.displayMetrics.density * 6).toInt()
|
|
||||||
layoutParams = lp
|
|
||||||
visibility = View.GONE
|
|
||||||
}
|
|
||||||
container.addView(lblUrl)
|
|
||||||
container.addView(urlInput)
|
|
||||||
|
|
||||||
// Buttons row - weighted buttons for consistent spacing
|
|
||||||
val buttonsRow =
|
|
||||||
LinearLayout(themed).apply {
|
|
||||||
orientation = LinearLayout.HORIZONTAL
|
|
||||||
val lp =
|
|
||||||
LinearLayout.LayoutParams(
|
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
|
||||||
)
|
|
||||||
lp.topMargin = (themed.resources.displayMetrics.density * 12).toInt()
|
|
||||||
layoutParams = lp
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createButton(text: String): Button {
|
|
||||||
return Button(themed).apply {
|
|
||||||
this.text = text
|
|
||||||
setAllCaps(false)
|
|
||||||
setTextColor(0xFFFFFFFF.toInt())
|
|
||||||
val bLp =
|
|
||||||
LinearLayout.LayoutParams(
|
|
||||||
0,
|
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
|
||||||
1f
|
|
||||||
)
|
|
||||||
bLp.marginEnd = (themed.resources.displayMetrics.density * 8).toInt()
|
|
||||||
layoutParams = bLp
|
|
||||||
minHeight = (themed.resources.displayMetrics.density * 36).toInt()
|
|
||||||
setBackgroundColor(0x00000000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val deleteBtn = createButton("Delete bundle")
|
|
||||||
val closeBtn = createButton("Close")
|
|
||||||
val saveBtn =
|
|
||||||
createButton("Save & Reload").apply {
|
|
||||||
setBackgroundColor(0xFFBB86FC.toInt())
|
|
||||||
setTextColor(0xFF000000.toInt())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add Delete and Close to the top row (equal width)
|
|
||||||
buttonsRow.addView(deleteBtn)
|
|
||||||
buttonsRow.addView(closeBtn)
|
|
||||||
container.addView(buttonsRow)
|
|
||||||
|
|
||||||
// Save button in its own row (full-width look)
|
|
||||||
val saveRow =
|
|
||||||
LinearLayout(themed).apply {
|
|
||||||
orientation = LinearLayout.HORIZONTAL
|
|
||||||
val lp =
|
|
||||||
LinearLayout.LayoutParams(
|
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
|
||||||
)
|
|
||||||
lp.topMargin = (themed.resources.displayMetrics.density * 8).toInt()
|
|
||||||
layoutParams = lp
|
|
||||||
}
|
|
||||||
|
|
||||||
val saveLp =
|
|
||||||
LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
|
||||||
saveBtn.layoutParams = saveLp
|
|
||||||
saveBtn.minHeight = (themed.resources.displayMetrics.density * 40).toInt()
|
|
||||||
saveRow.addView(saveBtn)
|
|
||||||
container.addView(saveRow)
|
|
||||||
|
|
||||||
// Initialize UI state
|
|
||||||
customSwitch.isChecked = customEnabled
|
|
||||||
urlInput.setText(customUrl)
|
|
||||||
urlInput.visibility = if (customEnabled) View.VISIBLE else View.GONE
|
|
||||||
|
|
||||||
// Toggle URL input visibility when switching custom URL on/off
|
|
||||||
customSwitch.setOnCheckedChangeListener { _, checked ->
|
|
||||||
urlInput.visibility = if (checked) View.VISIBLE else View.GONE
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save button: only persist custom URL config and then reload
|
|
||||||
saveBtn.setOnClickListener {
|
|
||||||
try {
|
|
||||||
val enabled = customSwitch.isChecked
|
|
||||||
val url = urlInput.text?.toString() ?: ""
|
|
||||||
val escapedUrl = url.replace("\\", "\\\\").replace("\"", "\\\"")
|
|
||||||
val json =
|
|
||||||
"{\"customLoadUrl\":{\"enabled\":$enabled,\"url\":\"$escapedUrl\"}}"
|
|
||||||
runCatching { configFile.writeText(json) }.onFailure { t ->
|
|
||||||
Log.e("Failed to write loader.json: ${t.message}", t)
|
|
||||||
XposedBridge.log(
|
|
||||||
"GoonXposed: write loader.json failed: ${android.util.Log.getStackTraceString(t)}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
Log.e("Failed to save loader.json: ${t.message}", t)
|
|
||||||
}
|
|
||||||
reloadApp()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete bundle
|
|
||||||
deleteBtn.setOnClickListener {
|
|
||||||
try {
|
|
||||||
val bundleFile =
|
|
||||||
File(
|
|
||||||
packageParam.appInfo.dataDir,
|
|
||||||
"${Constants.CACHE_DIR}/${Constants.MAIN_SCRIPT_FILE}"
|
|
||||||
)
|
|
||||||
if (bundleFile.exists()) bundleFile.delete()
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
Log.e("Failed to delete bundle: ${t.message}", t)
|
|
||||||
}
|
|
||||||
reloadApp()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close button dismisses the created dialog
|
|
||||||
val dlg = AlertDialog.Builder(themed).setView(container).create()
|
|
||||||
dlg.show()
|
|
||||||
closeBtn.setOnClickListener { dlg.dismiss() }
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
// Log and fallback to old simple dialog
|
|
||||||
Log.e("Dialog failed, falling back: ${e.message}", e)
|
|
||||||
XposedBridge.log(
|
|
||||||
"GoonXposed: Dialog failed: ${android.util.Log.getStackTraceString(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
|
||||||
AlertDialog.Builder(context)
|
|
||||||
.setTitle("GoonXposed 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()
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
Log.e("Fallback dialog also failed: ${t.message}", t)
|
|
||||||
XposedBridge.log(
|
|
||||||
"GoonXposed: Fallback dialog failed: ${android.util.Log.getStackTraceString(t)}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
package GoonXposed.xposed.modules
|
|
||||||
|
|
||||||
import GoonXposed.xposed.BuildConfig
|
|
||||||
import GoonXposed.xposed.Constants
|
|
||||||
import GoonXposed.xposed.Module
|
|
||||||
import GoonXposed.xposed.Utils.Companion.JSON
|
|
||||||
import GoonXposed.xposed.modules.HookScriptLoaderModule.Companion.PRELOADS_DIR
|
|
||||||
import android.content.res.AssetManager
|
|
||||||
import de.robv.android.xposed.XC_MethodHook
|
|
||||||
import de.robv.android.xposed.XposedBridge
|
|
||||||
import de.robv.android.xposed.callbacks.XC_LoadPackage
|
|
||||||
import java.io.File
|
|
||||||
import kotlinx.serialization.json.buildJsonObject
|
|
||||||
import kotlinx.serialization.json.put
|
|
||||||
|
|
||||||
// TODO: Remove this redundant module and merge its functionality into AdditionalBridgeMethodsModule
|
|
||||||
// if needed
|
|
||||||
class PayloadGlobalModule(private val modules: List<Module>) : Module() {
|
|
||||||
private companion object {
|
|
||||||
const val GLOBAL_NAME = "__PYON_LOADER__"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getPayloadString(): String =
|
|
||||||
JSON.encodeToString(
|
|
||||||
buildJsonObject {
|
|
||||||
put("loaderName", Constants.LOADER_NAME)
|
|
||||||
put("loaderVersion", BuildConfig.VERSION_NAME)
|
|
||||||
@Suppress("DEPRECATION") for (module in modules) module.buildPayload(this)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) =
|
|
||||||
with(packageParam) {
|
|
||||||
val catalystInstance =
|
|
||||||
classLoader.safeLoadClass("com.facebook.react.bridge.CatalystInstanceImpl")
|
|
||||||
val reactInstance =
|
|
||||||
classLoader.safeLoadClass("com.facebook.react.runtime.ReactInstance$1")
|
|
||||||
|
|
||||||
val setGlobalVariable: (XC_MethodHook.MethodHookParam, String, String) -> Unit =
|
|
||||||
{ param, key, json ->
|
|
||||||
runCatching {
|
|
||||||
// Attempt old CatalystInstanceImpl method
|
|
||||||
catalystInstance!!
|
|
||||||
.method(
|
|
||||||
"setGlobalVariable",
|
|
||||||
String::class.java,
|
|
||||||
String::class.java
|
|
||||||
)
|
|
||||||
.invoke(param.thisObject, key, json)
|
|
||||||
}
|
|
||||||
.onFailure {
|
|
||||||
// Failed (likely because it's a stub), use a workaround
|
|
||||||
val preloadsDir =
|
|
||||||
File(
|
|
||||||
"${appInfo.dataDir}/${Constants.FILES_DIR}",
|
|
||||||
PRELOADS_DIR
|
|
||||||
)
|
|
||||||
File(preloadsDir, "rv_globals_$key.js").apply {
|
|
||||||
writeText("this[${JSON.encodeToString(key)}]=$json")
|
|
||||||
|
|
||||||
XposedBridge.invokeOriginalMethod(
|
|
||||||
reactInstance!!.method(
|
|
||||||
"loadScriptFromFile",
|
|
||||||
String::class.java,
|
|
||||||
String::class.java,
|
|
||||||
Boolean::class.javaPrimitiveType
|
|
||||||
),
|
|
||||||
param.thisObject,
|
|
||||||
arrayOf(
|
|
||||||
absolutePath,
|
|
||||||
absolutePath,
|
|
||||||
param.args[2]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
delete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val hook =
|
|
||||||
MethodHookBuilder().run {
|
|
||||||
before { setGlobalVariable(param, GLOBAL_NAME, getPayloadString()) }
|
|
||||||
|
|
||||||
build()
|
|
||||||
}
|
|
||||||
|
|
||||||
listOf(catalystInstance, reactInstance).forEach { if (it != null) hook(it, hook) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hook(instance: Class<*>, hook: XC_MethodHook) {
|
|
||||||
instance.method(
|
|
||||||
"loadScriptFromAssets",
|
|
||||||
AssetManager::class.java,
|
|
||||||
String::class.java,
|
|
||||||
Boolean::class.javaPrimitiveType,
|
|
||||||
)
|
|
||||||
.hook(hook)
|
|
||||||
|
|
||||||
instance.method(
|
|
||||||
"loadScriptFromFile",
|
|
||||||
String::class.java,
|
|
||||||
String::class.java,
|
|
||||||
Boolean::class.javaPrimitiveType
|
|
||||||
)
|
|
||||||
.hook(hook)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,148 +0,0 @@
|
||||||
package GoonXposed.xposed.modules
|
|
||||||
|
|
||||||
import GoonXposed.xposed.Constants
|
|
||||||
import GoonXposed.xposed.Module
|
|
||||||
import GoonXposed.xposed.Utils.Companion.JSON
|
|
||||||
import GoonXposed.xposed.Utils.Log
|
|
||||||
import android.app.Activity
|
|
||||||
import android.util.AtomicFile
|
|
||||||
import androidx.core.util.writeBytes
|
|
||||||
import de.robv.android.xposed.callbacks.XC_LoadPackage
|
|
||||||
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 java.io.File
|
|
||||||
import kotlinx.coroutines.*
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@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://github.com/OTKUSteyler/GoonCord/releases/download/1.1.5/goooncord.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) {
|
|
||||||
var timeoutMs = TIMEOUT_CACHED
|
|
||||||
if (!bundle.exists()) {
|
|
||||||
timeoutMs = TIMEOUT
|
|
||||||
}
|
|
||||||
timeout { requestTimeoutMillis = timeoutMs }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,271 +0,0 @@
|
||||||
package GoonXposed.xposed.modules.appearance
|
|
||||||
|
|
||||||
import GoonXposed.xposed.Constants
|
|
||||||
import GoonXposed.xposed.Module
|
|
||||||
import GoonXposed.xposed.Utils.Companion.JSON
|
|
||||||
import GoonXposed.xposed.Utils.Log
|
|
||||||
import android.content.res.AssetManager
|
|
||||||
import android.graphics.Typeface
|
|
||||||
import android.graphics.Typeface.CustomFallbackBuilder
|
|
||||||
import android.graphics.fonts.Font
|
|
||||||
import android.graphics.fonts.FontFamily
|
|
||||||
import android.os.Build
|
|
||||||
import de.robv.android.xposed.XC_MethodReplacement
|
|
||||||
import de.robv.android.xposed.XposedHelpers
|
|
||||||
import de.robv.android.xposed.callbacks.XC_LoadPackage
|
|
||||||
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 java.io.File
|
|
||||||
import java.io.IOException
|
|
||||||
import kotlinx.coroutines.*
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.json.JsonObjectBuilder
|
|
||||||
import kotlinx.serialization.json.put
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class FontDefinition(
|
|
||||||
val name: String? = null,
|
|
||||||
val description: String? = null,
|
|
||||||
val spec: Int? = null,
|
|
||||||
val main: Map<String, String>,
|
|
||||||
)
|
|
||||||
|
|
||||||
class FontsModule : Module() {
|
|
||||||
private companion object {
|
|
||||||
val EXTENSIONS = arrayOf("", "_bold", "_italic", "_bold_italic")
|
|
||||||
val FILE_EXTENSIONS = arrayOf(".ttf", ".otf")
|
|
||||||
const val FONTS_ASSET_PATH = "fonts/"
|
|
||||||
}
|
|
||||||
|
|
||||||
private lateinit var fontsDir: File
|
|
||||||
private lateinit var fontsDownloadsDir: File
|
|
||||||
private var fontsAbsPath: String? = null
|
|
||||||
|
|
||||||
override fun buildPayload(builder: JsonObjectBuilder) {
|
|
||||||
builder.apply { put("fontPatch", 2) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) =
|
|
||||||
with(packageParam) {
|
|
||||||
XposedHelpers.findAndHookMethod(
|
|
||||||
"com.facebook.react.views.text.ReactFontManager\$Companion",
|
|
||||||
classLoader,
|
|
||||||
"createAssetTypeface",
|
|
||||||
String::class.java,
|
|
||||||
Int::class.java,
|
|
||||||
"android.content.res.AssetManager",
|
|
||||||
object : XC_MethodReplacement() {
|
|
||||||
override fun replaceHookedMethod(param: MethodHookParam): Typeface? {
|
|
||||||
val fontFamilyName: String = param.args[0].toString()
|
|
||||||
val style: Int = param.args[1] as Int
|
|
||||||
val assetManager: AssetManager = param.args[2] as AssetManager
|
|
||||||
return createAssetTypeface(fontFamilyName, style, assetManager)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
val fontDefFile =
|
|
||||||
File(appInfo.dataDir, "${Constants.FILES_DIR}/fonts.json").apply {
|
|
||||||
asFile()
|
|
||||||
}
|
|
||||||
if (!fontDefFile.exists()) return@with
|
|
||||||
|
|
||||||
val fontDef =
|
|
||||||
try {
|
|
||||||
JSON.decodeFromString<FontDefinition>(fontDefFile.readText())
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
return@with
|
|
||||||
}
|
|
||||||
|
|
||||||
fontsDownloadsDir =
|
|
||||||
File(appInfo.dataDir, "${Constants.FILES_DIR}/downloads/fonts").apply {
|
|
||||||
asDir()
|
|
||||||
}
|
|
||||||
fontsDir = File(fontsDownloadsDir, fontDef.name!!).apply { asDir() }
|
|
||||||
fontsAbsPath = fontsDir.absolutePath + "/"
|
|
||||||
|
|
||||||
fontsDir.listFiles()?.forEach { file ->
|
|
||||||
val fileName = file.name
|
|
||||||
if (!fileName.startsWith(".")) {
|
|
||||||
val fontName = fileName.split('.')[0]
|
|
||||||
if (fontDef.main.keys.none { it == fontName }) {
|
|
||||||
Log.i("Deleting font file: $fileName")
|
|
||||||
file.delete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// These files should be downloaded by the JS side, but oh well
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
fontDef.main
|
|
||||||
.keys
|
|
||||||
.map { name ->
|
|
||||||
async {
|
|
||||||
val url = fontDef.main.getValue(name)
|
|
||||||
try {
|
|
||||||
Log.i("Downloading $name from $url")
|
|
||||||
val file =
|
|
||||||
File(
|
|
||||||
fontsDir,
|
|
||||||
"$name${FILE_EXTENSIONS.first { url.endsWith(it) }}"
|
|
||||||
)
|
|
||||||
.apply { asFile() }
|
|
||||||
if (file.exists()) return@async
|
|
||||||
|
|
||||||
val client =
|
|
||||||
HttpClient(CIO) {
|
|
||||||
install(UserAgent) {
|
|
||||||
agent = Constants.USER_AGENT
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val response: HttpResponse = client.get(url)
|
|
||||||
if (response.status == HttpStatusCode.OK)
|
|
||||||
file.writeBytes(response.body())
|
|
||||||
|
|
||||||
return@async
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Log.e("Failed to download fonts ($name from $url)", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.awaitAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createAssetTypefaceWithFallbacks(
|
|
||||||
fontFamilyNames: Array<String>,
|
|
||||||
style: Int,
|
|
||||||
assetManager: AssetManager
|
|
||||||
): Typeface? {
|
|
||||||
val fontFamilies: MutableList<FontFamily> = ArrayList()
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
// Iterate over the list of fontFamilyNames, constructing new FontFamily objects
|
|
||||||
// for use in the CustomFallbackBuilder below.
|
|
||||||
for (fontFamilyName in fontFamilyNames) {
|
|
||||||
try {
|
|
||||||
for (fileExtension in FILE_EXTENSIONS) {
|
|
||||||
val (customName, refName) = fontFamilyName.split(":")
|
|
||||||
val file =
|
|
||||||
File(fontsDownloadsDir, "$customName/$refName.$fileExtension")
|
|
||||||
.apply { asFile() }
|
|
||||||
val font = Font.Builder(file).build()
|
|
||||||
val family = FontFamily.Builder(font).build()
|
|
||||||
fontFamilies.add(family)
|
|
||||||
}
|
|
||||||
} catch (_: Throwable) {}
|
|
||||||
|
|
||||||
for (fontRootPath in arrayOf(fontsAbsPath, FONTS_ASSET_PATH).filterNotNull()) {
|
|
||||||
for (fileExtension in FILE_EXTENSIONS) {
|
|
||||||
val fileName =
|
|
||||||
StringBuilder()
|
|
||||||
.append(fontRootPath)
|
|
||||||
.append(fontFamilyName)
|
|
||||||
.append(fileExtension)
|
|
||||||
.toString()
|
|
||||||
try {
|
|
||||||
val builder =
|
|
||||||
if (fileName[0] == '/') Font.Builder(File(fileName))
|
|
||||||
else Font.Builder(assetManager, fileName)
|
|
||||||
val font = builder.build()
|
|
||||||
val family = FontFamily.Builder(font).build()
|
|
||||||
fontFamilies.add(family)
|
|
||||||
} catch (_: RuntimeException) {
|
|
||||||
// If the typeface asset does not exist, try another extension.
|
|
||||||
continue
|
|
||||||
} catch (_: IOException) {
|
|
||||||
// If the font asset does not exist, try another extension.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If there's some problem constructing fonts, fall back to the default behavior.
|
|
||||||
if (fontFamilies.isEmpty())
|
|
||||||
return createAssetTypeface(fontFamilyNames[0], style, assetManager)
|
|
||||||
|
|
||||||
val fallbackBuilder = CustomFallbackBuilder(fontFamilies[0])
|
|
||||||
for (i in 1 until fontFamilies.size) {
|
|
||||||
fallbackBuilder.addCustomFallback(fontFamilies[i])
|
|
||||||
}
|
|
||||||
return fallbackBuilder.build()
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createAssetTypeface(
|
|
||||||
fontFamilyName: String,
|
|
||||||
style: Int,
|
|
||||||
assetManager: AssetManager
|
|
||||||
): Typeface? {
|
|
||||||
// This logic attempts to safely check if the frontend code is attempting to use
|
|
||||||
// fallback fonts, and if it is, to use the fallback typeface creation logic.
|
|
||||||
var fontFamilyName: String = fontFamilyName
|
|
||||||
val fontFamilyNames =
|
|
||||||
fontFamilyName.split(",".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
|
|
||||||
for (i in fontFamilyNames.indices) {
|
|
||||||
fontFamilyNames[i] = fontFamilyNames[i].trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
// If there are multiple font family names:
|
|
||||||
// For newer versions of Android, construct a Typeface with fallbacks
|
|
||||||
// For older versions of Android, ignore all the fallbacks and just use the first font
|
|
||||||
// family
|
|
||||||
if (fontFamilyNames.size > 1) {
|
|
||||||
fontFamilyName =
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
return createAssetTypefaceWithFallbacks(
|
|
||||||
fontFamilyNames,
|
|
||||||
style,
|
|
||||||
assetManager
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
fontFamilyNames[0]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val extension = EXTENSIONS[style]
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (fileExtension in FILE_EXTENSIONS) {
|
|
||||||
val (customName, refName) = fontFamilyName.split(":")
|
|
||||||
val file =
|
|
||||||
File(fontsDownloadsDir, "$customName/$refName.$fileExtension").apply {
|
|
||||||
asFile()
|
|
||||||
}
|
|
||||||
if (!file.exists()) throw Exception()
|
|
||||||
return Typeface.createFromFile(file.absolutePath)
|
|
||||||
}
|
|
||||||
} catch (_: Throwable) {}
|
|
||||||
|
|
||||||
// Lastly, after all those checks above, this is the original RN logic for
|
|
||||||
// getting the typeface.
|
|
||||||
for (fontRootPath in arrayOf(fontsAbsPath, FONTS_ASSET_PATH).filterNotNull()) {
|
|
||||||
for (fileExtension in FILE_EXTENSIONS) {
|
|
||||||
val fileName =
|
|
||||||
StringBuilder()
|
|
||||||
.append(fontRootPath)
|
|
||||||
.append(fontFamilyName)
|
|
||||||
.append(extension)
|
|
||||||
.append(fileExtension)
|
|
||||||
.toString()
|
|
||||||
|
|
||||||
return try {
|
|
||||||
if (fileName[0] == '/') Typeface.createFromFile(fileName)
|
|
||||||
else Typeface.createFromAsset(assetManager, fileName)
|
|
||||||
} catch (_: RuntimeException) {
|
|
||||||
// If the typeface asset does not exist, try another extension.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Typeface.create(fontFamilyName, style)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
package GoonXposed.xposed.modules.appearance
|
|
||||||
|
|
||||||
import GoonXposed.xposed.Module
|
|
||||||
import android.R.color
|
|
||||||
import android.app.AndroidAppHelper
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import kotlinx.serialization.json.*
|
|
||||||
|
|
||||||
class SysColorsModule : Module() {
|
|
||||||
private lateinit var context: Context
|
|
||||||
private fun isSupported() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
|
||||||
|
|
||||||
override fun buildPayload(builder: JsonObjectBuilder) {
|
|
||||||
context = AndroidAppHelper.currentApplication()
|
|
||||||
val accents = arrayOf("accent1", "accent2", "accent3", "neutral1", "neutral2")
|
|
||||||
val shades = arrayOf(0, 10, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000)
|
|
||||||
|
|
||||||
builder.apply {
|
|
||||||
put("isSysColorsSupported", isSupported())
|
|
||||||
if (isSupported())
|
|
||||||
putJsonObject("sysColors") {
|
|
||||||
for (accent in accents) putJsonArray(accent) {
|
|
||||||
for (shade in shades) {
|
|
||||||
val colorName = "system_" + accent + "_" + shade
|
|
||||||
|
|
||||||
val colorResourceId =
|
|
||||||
runCatching {
|
|
||||||
color::class.java.getField(colorName).getInt(null)
|
|
||||||
}
|
|
||||||
.getOrElse { 0 }
|
|
||||||
|
|
||||||
add(convertToColor(colorResourceId))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun convertToColor(id: Int): String {
|
|
||||||
val clr = if (isSupported()) ContextCompat.getColor(context, id) else 0
|
|
||||||
return String.format("#%06X", 0xFFFFFF and clr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,160 +0,0 @@
|
||||||
package GoonXposed.xposed.module.appearance
|
|
||||||
|
|
||||||
import GoonXposed.xposed.Constants
|
|
||||||
import GoonXposed.xposed.Module
|
|
||||||
import GoonXposed.xposed.Utils.Companion.JSON
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.res.Resources
|
|
||||||
import androidx.core.graphics.toColorInt
|
|
||||||
import de.robv.android.xposed.callbacks.XC_LoadPackage
|
|
||||||
import java.io.File
|
|
||||||
import kotlinx.serialization.ExperimentalSerializationApi
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.json.JsonObjectBuilder
|
|
||||||
import kotlinx.serialization.json.encodeToJsonElement
|
|
||||||
import kotlinx.serialization.json.put
|
|
||||||
|
|
||||||
@Serializable data class Author(val name: String, val id: String? = null)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class ThemeData(
|
|
||||||
val name: String,
|
|
||||||
val description: String? = null,
|
|
||||||
val authors: List<Author>? = null,
|
|
||||||
val spec: Int,
|
|
||||||
val semanticColors: Map<String, List<String>>? = null,
|
|
||||||
val rawColors: Map<String, String>? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable data class Theme(val id: String, val selected: Boolean, val data: ThemeData)
|
|
||||||
|
|
||||||
class ThemesModule : Module() {
|
|
||||||
private lateinit var param: XC_LoadPackage.LoadPackageParam
|
|
||||||
|
|
||||||
private var theme: Theme? = null
|
|
||||||
private val rawColorMap = mutableMapOf<String, Int>()
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val THEME_FILE = "current-theme.json"
|
|
||||||
}
|
|
||||||
|
|
||||||
@ExperimentalSerializationApi
|
|
||||||
override fun buildPayload(builder: JsonObjectBuilder) {
|
|
||||||
builder.apply {
|
|
||||||
put("hasThemeSupport", true)
|
|
||||||
if (theme != null) put("storedTheme", JSON.encodeToJsonElement<Theme>(theme!!))
|
|
||||||
else put("storedTheme", null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String.fromScreamingSnakeToCamelCase() =
|
|
||||||
this.split("_").joinToString("") { it ->
|
|
||||||
it.lowercase().replaceFirstChar { it.uppercase() }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) {
|
|
||||||
param = packageParam
|
|
||||||
theme = getTheme()
|
|
||||||
hookTheme()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun File.isValidish(): Boolean {
|
|
||||||
if (!this.exists()) return false
|
|
||||||
|
|
||||||
val text = this.readText()
|
|
||||||
return text.isNotBlank() && text != "{}" && text != "null"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getTheme(): Theme? {
|
|
||||||
val themeFile =
|
|
||||||
File(param.appInfo.dataDir, "${Constants.FILES_DIR}/${THEME_FILE}").apply {
|
|
||||||
asFile()
|
|
||||||
}
|
|
||||||
if (!themeFile.isValidish()) return null
|
|
||||||
|
|
||||||
return try {
|
|
||||||
val themeText = themeFile.readText()
|
|
||||||
JSON.decodeFromString<Theme>(themeText)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun hookTheme() {
|
|
||||||
val themeManager = param.classLoader.loadClass("com.discord.theme.utils.ColorUtilsKt")
|
|
||||||
val darkTheme = param.classLoader.loadClass("com.discord.theme.DarkerTheme")
|
|
||||||
val lightTheme = param.classLoader.loadClass("com.discord.theme.LightTheme")
|
|
||||||
|
|
||||||
val theme = this.theme ?: return
|
|
||||||
|
|
||||||
// Apply rawColors
|
|
||||||
theme.data.rawColors?.forEach { (key, value) ->
|
|
||||||
rawColorMap[key.lowercase()] = hexStringToColorInt(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply semanticColors
|
|
||||||
theme.data.semanticColors?.forEach { (key, value) ->
|
|
||||||
// TEXT_NORMAL -> getTextNormal
|
|
||||||
val methodName = "get${key.fromScreamingSnakeToCamelCase()}"
|
|
||||||
value.forEachIndexed { index, v ->
|
|
||||||
when (index) {
|
|
||||||
0 -> hookThemeMethod(darkTheme, methodName, hexStringToColorInt(v))
|
|
||||||
1 -> hookThemeMethod(lightTheme, methodName, hexStringToColorInt(v))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If there's any rawColors value, hook the color getter
|
|
||||||
if (!theme.data.rawColors.isNullOrEmpty()) {
|
|
||||||
val getColorCompat =
|
|
||||||
themeManager.getDeclaredMethod(
|
|
||||||
"getColorCompat",
|
|
||||||
Resources::class.java,
|
|
||||||
Int::class.javaPrimitiveType,
|
|
||||||
Resources.Theme::class.java,
|
|
||||||
)
|
|
||||||
|
|
||||||
val getColorCompatLegacy =
|
|
||||||
themeManager.getDeclaredMethod(
|
|
||||||
"getColorCompat",
|
|
||||||
Context::class.java,
|
|
||||||
Int::class.javaPrimitiveType
|
|
||||||
)
|
|
||||||
|
|
||||||
val patch =
|
|
||||||
MethodHookBuilder().run {
|
|
||||||
before {
|
|
||||||
val arg1 = args[0]
|
|
||||||
val resources =
|
|
||||||
if (arg1 is Context) arg1.resources else (arg1 as Resources)
|
|
||||||
val name = resources.getResourceEntryName(args[1] as Int)
|
|
||||||
|
|
||||||
if (rawColorMap[name] != null) result = rawColorMap[name]
|
|
||||||
}
|
|
||||||
|
|
||||||
build()
|
|
||||||
}
|
|
||||||
|
|
||||||
getColorCompat.hook(patch)
|
|
||||||
getColorCompatLegacy.hook(patch)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse HEX colour string to INT. Takes "#RRGGBBAA" or "#RRGGBB"
|
|
||||||
private fun hexStringToColorInt(hexString: String): Int {
|
|
||||||
return if (hexString.length == 9) {
|
|
||||||
// Rearrange RRGGBBAA -> AARRGGBB so parseColor() is happy
|
|
||||||
val alpha = hexString.substring(7, 9)
|
|
||||||
val rrggbb = hexString.substring(1, 7)
|
|
||||||
"#$alpha$rrggbb".toColorInt()
|
|
||||||
} else hexString.toColorInt()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hookThemeMethod(themeClass: Class<*>, methodName: String, themeValue: Int) {
|
|
||||||
try {
|
|
||||||
themeClass.getDeclaredMethod(methodName).let { method ->
|
|
||||||
method.hook { before { result = themeValue } }
|
|
||||||
}
|
|
||||||
} catch (_: NoSuchMethodException) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
package GoonXposed.xposed.modules.bridge
|
|
||||||
|
|
||||||
import GoonXposed.xposed.Module
|
|
||||||
import android.app.AlertDialog
|
|
||||||
import android.content.ClipData
|
|
||||||
import android.content.ClipboardManager
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Build
|
|
||||||
import android.widget.Toast
|
|
||||||
|
|
||||||
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("GoonXposed Error")
|
|
||||||
.setMessage(
|
|
||||||
"""
|
|
||||||
GoonXposed: $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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,208 +0,0 @@
|
||||||
package GoonXposed.xposed.modules.bridge
|
|
||||||
|
|
||||||
import GoonXposed.xposed.BuildConfig
|
|
||||||
import GoonXposed.xposed.Constants
|
|
||||||
import GoonXposed.xposed.Module
|
|
||||||
import GoonXposed.xposed.Utils.Log
|
|
||||||
import de.robv.android.xposed.callbacks.XC_LoadPackage
|
|
||||||
import java.io.File
|
|
||||||
import java.lang.reflect.Method
|
|
||||||
|
|
||||||
/**
|
|
||||||
* See for possible return types:
|
|
||||||
* https://github.com/facebook/react-native/blob/c23e84ae9/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/Arguments.kt#L19
|
|
||||||
*
|
|
||||||
* You may return a [Unit] and the resulting value will be `null`.
|
|
||||||
*/
|
|
||||||
typealias BridgeMethodCallback = (args: BridgeMethodArgs) -> Any?
|
|
||||||
|
|
||||||
typealias BridgeMethodArgs = ArrayList<Any>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A module that exposes a bridge for calling methods from JavaScript.
|
|
||||||
*
|
|
||||||
* To call a method, pass an object with the following structure to a hooked method:
|
|
||||||
* ```js
|
|
||||||
* {
|
|
||||||
* revenge: {
|
|
||||||
* method: "method.name",
|
|
||||||
* args: [arg1, arg2, ...]
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* For methods that accept additional arguments, only the first object argument is used for the
|
|
||||||
* bridge call.
|
|
||||||
*
|
|
||||||
* The result will be an object with either a `result` or `error` key:
|
|
||||||
* ```js
|
|
||||||
* {
|
|
||||||
* result: ... // The return value of the method
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
* or
|
|
||||||
* ```js
|
|
||||||
* {
|
|
||||||
* error: "Error message"
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
class BridgeModule : Module() {
|
|
||||||
private lateinit var readableMapGetString: Method
|
|
||||||
private lateinit var readableMapToHashMapMethod: Method
|
|
||||||
private lateinit var argumentsMakeNative: Method
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val CALL_DATA_KEY = "revenge"
|
|
||||||
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
|
|
||||||
{
|
|
||||||
mapOf(
|
|
||||||
"name" to Constants.LOADER_NAME,
|
|
||||||
"version" to BuildConfig.VERSION_CODE
|
|
||||||
)
|
|
||||||
},
|
|
||||||
"revenge.test" to
|
|
||||||
{
|
|
||||||
mapOf(
|
|
||||||
"string" to "string",
|
|
||||||
"number" to 7256,
|
|
||||||
"array" to listOf("testing", 527737, listOf(true)),
|
|
||||||
"object" to mapOf("nested" to true),
|
|
||||||
"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)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registers a bridge method that can be called from JavaScript.
|
|
||||||
*
|
|
||||||
* If the method is already registered, it will be overwritten.
|
|
||||||
*
|
|
||||||
* @param name The name of the method to register.
|
|
||||||
* @param callback The callback to invoke when the method is called.
|
|
||||||
*/
|
|
||||||
fun registerMethod(name: String, callback: BridgeMethodCallback) {
|
|
||||||
if (methods.containsKey(name))
|
|
||||||
Log.w("Bridge method already exists and will be overridden: $name")
|
|
||||||
methods[name] = callback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) {
|
|
||||||
with(packageParam) {
|
|
||||||
val arguments = classLoader.loadClass("com.facebook.react.bridge.Arguments")
|
|
||||||
val readableMap = classLoader.loadClass("com.facebook.react.bridge.ReadableMap")
|
|
||||||
val promise = classLoader.loadClass("com.facebook.react.bridge.Promise")
|
|
||||||
|
|
||||||
val promiseResolve = promise.method("resolve", Object::class.java)
|
|
||||||
argumentsMakeNative = arguments.method("makeNativeObject", Object::class.java)
|
|
||||||
readableMapGetString = readableMap.method("getString", String::class.java)
|
|
||||||
readableMapToHashMapMethod = readableMap.method("toHashMap")
|
|
||||||
|
|
||||||
classLoader.loadClass("com.horcrux.svg.RNSVGRenderableManager").hookMethod(
|
|
||||||
"getBBox",
|
|
||||||
Double::class.javaObjectType,
|
|
||||||
readableMap
|
|
||||||
) {
|
|
||||||
before {
|
|
||||||
callBridgeMethod(readableMapToHashMapConverter(args[1]!!))?.let {
|
|
||||||
result = it.toNativeObject()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
classLoader.loadClass("com.facebook.react.modules.blob.FileReaderModule").hookMethod(
|
|
||||||
"readAsDataURL",
|
|
||||||
readableMap,
|
|
||||||
promise
|
|
||||||
) {
|
|
||||||
before {
|
|
||||||
// Avoid shadowing the outer `readableMap` and `promise` variables
|
|
||||||
val (readableMapArg, promiseArg) = args
|
|
||||||
callBridgeMethod(readableMapToHashMapConverter(readableMapArg!!))?.let {
|
|
||||||
promiseResolve.invoke(promiseArg, it.toNativeObject())
|
|
||||||
result = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Any?.toNativeObject(): Any? =
|
|
||||||
argumentsMakeNative.invoke(
|
|
||||||
null,
|
|
||||||
when (this) {
|
|
||||||
Unit -> null
|
|
||||||
else -> this
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun readableMapToHashMapConverter(map: Any): HashMap<String, Any?> {
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
return readableMapToHashMapMethod.invoke(map) as HashMap<String, Any?>
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun callBridgeMethod(hashMap: HashMap<String, Any?>): Map<String, Any?>? {
|
|
||||||
try {
|
|
||||||
val pair = getBridgeCallData(hashMap) ?: return null
|
|
||||||
val (method, args) = pair
|
|
||||||
val ret = method(args).toNativeObject()
|
|
||||||
return mapOf("result" to ret)
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
return mapOf("error" to e.toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getBridgeCallData(
|
|
||||||
hashMap: HashMap<String, Any?>
|
|
||||||
): Pair<BridgeMethodCallback, BridgeMethodArgs>? {
|
|
||||||
@Suppress("UNCHECKED_CAST") val data = hashMap[CALL_DATA_KEY] as HashMap<String, Any?>?
|
|
||||||
data ?: return null
|
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST") val name = data[METHOD_NAME_KEY] as String
|
|
||||||
val method = methods[name]
|
|
||||||
method ?: throw Error("Method not registered: $name")
|
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST") val args = data[METHOD_ARGS_KEY] as BridgeMethodArgs
|
|
||||||
|
|
||||||
return Pair(method, args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -20,4 +20,6 @@ kotlin.code.style=official
|
||||||
# Enables namespacing of each library's R class so that its R class includes only the
|
# Enables namespacing of each library's R class so that its R class includes only the
|
||||||
# resources declared in the library itself and none from the library's dependencies,
|
# resources declared in the library itself and none from the library's dependencies,
|
||||||
# thereby reducing the size of the R class for that library
|
# thereby reducing the size of the R class for that library
|
||||||
android.nonTransitiveRClass=true
|
android.nonTransitiveRClass=true
|
||||||
|
# Suppress deprecation warning when building with a newer JDK (e.g., Java 21)
|
||||||
|
android.javaCompile.suppressSourceTargetDeprecationWarning=true
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
rootProject.name = "KettuXposed"
|
rootProject.name = "ShiggyXposed"
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue