mirror of
https://github.com/geode-sdk/android-launcher.git
synced 2026-10-21 08:19:33 +00:00
Compare commits
438
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1658072b1a | ||
|
|
b6e88b20cb | ||
|
|
f4af50b2cb | ||
|
|
00c47f4c70 | ||
|
|
11649a236a | ||
|
|
ff712196cc | ||
|
|
6c57f3f261 | ||
|
|
626f8d00d1 | ||
|
|
45ecc71749 | ||
|
|
3d60bd3d53 | ||
|
|
e40af4c49b | ||
|
|
e0a31694af | ||
|
|
c8994c2484 | ||
|
|
35c8428f08 | ||
|
|
bca761f26a | ||
|
|
79c06ca629 | ||
|
|
3194dff7ff | ||
|
|
9e86bbe355 | ||
|
|
eef9160970 | ||
|
|
3b4951def1 | ||
|
|
ef44d48ed0 | ||
|
|
aae2aa2290 | ||
|
|
0a62082519 | ||
|
|
758d5ad432 | ||
|
|
b3a776ce7c | ||
|
|
72f74fa084 | ||
|
|
0dba4f0606 | ||
|
|
2016aa4e90 | ||
|
|
38bc97b7b6 | ||
|
|
0a4f13789f | ||
|
|
3cb0998274 | ||
|
|
5d70cb484f | ||
|
|
a27e67b41e | ||
|
|
8bff33c013 | ||
|
|
c9c51f0032 | ||
|
|
82b6cf309f | ||
|
|
a7654cfc69 | ||
|
|
c1d136c0cc | ||
|
|
269f2fbd02 | ||
|
|
21c3374248 | ||
|
|
120f412986 | ||
|
|
633543f363 | ||
|
|
d6b4157cdc | ||
|
|
482e3bc5d0 | ||
|
|
15e24b566e | ||
|
|
0de077ec1d | ||
|
|
655587e1ee | ||
|
|
d3ad272ffc | ||
|
|
79632c9844 | ||
|
|
8f5b1b4218 | ||
|
|
23ed997640 | ||
|
|
acaff15d4d | ||
|
|
669ea87be0 | ||
|
|
b668e5032b | ||
|
|
4508abc155 | ||
|
|
38a994436f | ||
|
|
5ab71d6c62 | ||
|
|
3b88a32e8f | ||
|
|
0ceecbbc72 | ||
|
|
714f750f77 | ||
|
|
69188075a3 | ||
|
|
187d3f9819 | ||
|
|
559cedb0f0 | ||
|
|
232b0a52ec | ||
|
|
915c46f05d | ||
|
|
a6ceee55d8 | ||
|
|
f2697cfb3b | ||
|
|
1bb68349e5 | ||
|
|
a677cab912 | ||
|
|
5caa533a4a | ||
|
|
1a68cc5eb9 | ||
|
|
ce98734bb9 | ||
|
|
f17500ef7b | ||
|
|
ea3ed8415b | ||
|
|
41664e430c | ||
|
|
8b17e6591f | ||
|
|
1d47a54573 | ||
|
|
9307c74df4 | ||
|
|
df0cebaa89 | ||
|
|
7bab94bf40 | ||
|
|
6d5de931fc | ||
|
|
c404f977e5 | ||
|
|
3202851303 | ||
|
|
d1fdb26d44 | ||
|
|
4987aa00db | ||
|
|
97b1cc288f | ||
|
|
00367096e9 | ||
|
|
0a7a366056 | ||
|
|
60ce6ddee8 | ||
|
|
6c1cc68ad0 | ||
|
|
b4da272a35 | ||
|
|
4745a3d36c | ||
|
|
2bf633922e | ||
|
|
02c7399eea | ||
|
|
d8985e14cc | ||
|
|
c2125248b6 | ||
|
|
e6bac1e25a | ||
|
|
eab0de3a34 | ||
|
|
9b7d174704 | ||
|
|
4c0d9b88d2 | ||
|
|
1adae94859 | ||
|
|
5edd63f78f | ||
|
|
3a54c34580 | ||
|
|
f12f480a38 | ||
|
|
1d7cd9594d | ||
|
|
9d4c27ac92 | ||
|
|
2754cc4bb8 | ||
|
|
0d01f8c74b | ||
|
|
24dd71a96d | ||
|
|
1f1e0de523 | ||
|
|
d0715c1bd8 | ||
|
|
41a5f560a3 | ||
|
|
3c43d8be22 | ||
|
|
f3d36259a1 | ||
|
|
6007fee0b8 | ||
|
|
afad922deb | ||
|
|
4df6da6521 | ||
|
|
d9b0b74762 | ||
|
|
a28e6d96ab | ||
|
|
c085409cee | ||
|
|
76c97a8d71 | ||
|
|
36fcbb5c52 | ||
|
|
e47f044189 | ||
|
|
426a1a1ade | ||
|
|
e786599e73 | ||
|
|
b08e2b4a3f | ||
|
|
41f663d55e | ||
|
|
b7d3d3e730 | ||
|
|
92d876560a | ||
|
|
b69c915e51 | ||
|
|
2b05f68af9 | ||
|
|
47d216b486 | ||
|
|
b4dd1567c9 | ||
|
|
8b2544434d | ||
|
|
24b0e39658 | ||
|
|
f763534a72 | ||
|
|
3887c3fe60 | ||
|
|
53a67f3e43 | ||
|
|
150bac781b | ||
|
|
0455a19f18 | ||
|
|
3a6c731710 | ||
|
|
2dde48c8fc | ||
|
|
f52b90b259 | ||
|
|
fa69a649dd | ||
|
|
a16c76cd06 | ||
|
|
9b5d614d1a | ||
|
|
d6a6de8361 | ||
|
|
2797e52683 | ||
|
|
69b00fecf5 | ||
|
|
f0eddd1652 | ||
|
|
d7190fa928 | ||
|
|
8e8a56da2e | ||
|
|
b418e154da | ||
|
|
cd306a5b5e | ||
|
|
fb9a6d1912 | ||
|
|
e276e302ab | ||
|
|
d7c779fce3 | ||
|
|
1e0dcc91ec | ||
|
|
266055e6d5 | ||
|
|
ab8e3f83d2 | ||
|
|
a75d68e5e0 | ||
|
|
13191dc66f | ||
|
|
b7f378b947 | ||
|
|
25367d5650 | ||
|
|
aa51c201e5 | ||
|
|
0b92f4a318 | ||
|
|
7d75825d01 | ||
|
|
c77271f45c | ||
|
|
09bb28d66f | ||
|
|
157952b6f7 | ||
|
|
352ff63ab0 | ||
|
|
91fc804dee | ||
|
|
87808b56b3 | ||
|
|
2c2884c3b8 | ||
|
|
74e91e7dc1 | ||
|
|
e3ffbf311e | ||
|
|
5ad77bca57 | ||
|
|
3be0127e04 | ||
|
|
458c837aba | ||
|
|
67ca7707d8 | ||
|
|
776a4fa468 | ||
|
|
9528eef313 | ||
|
|
9827fe36c1 | ||
|
|
ce44270b7a | ||
|
|
34e324c54d | ||
|
|
986e33ce03 | ||
|
|
91c06bde8c | ||
|
|
a9d40a7594 | ||
|
|
bb6146be37 | ||
|
|
071cf40d42 | ||
|
|
19f47f138a | ||
|
|
c070b5cfe0 | ||
|
|
4782431bae | ||
|
|
b63073c135 | ||
|
|
c029fed37d | ||
|
|
dacabda792 | ||
|
|
824d4395c7 | ||
|
|
bd502b56c2 | ||
|
|
6991598fcb | ||
|
|
2cc8e7215a | ||
|
|
5e81d1c70e | ||
|
|
b03ff6b735 | ||
|
|
220c1b0dd6 | ||
|
|
0a4fb091f4 | ||
|
|
110f74400c | ||
|
|
eed8020229 | ||
|
|
ceb3d5b461 | ||
|
|
53cd339213 | ||
|
|
327e9b4d39 | ||
|
|
10a0670a47 | ||
|
|
500d3ba015 | ||
|
|
1236180425 | ||
|
|
129ee40c0d | ||
|
|
a687f8464e | ||
|
|
b409a200bb | ||
|
|
acb07db6f2 | ||
|
|
0ae65971fc | ||
|
|
c4bd4333ca | ||
|
|
f9e30e549a | ||
|
|
1583f259b9 | ||
|
|
c090754129 | ||
|
|
31227860f4 | ||
|
|
edc2428f48 | ||
|
|
6f3473b4ca | ||
|
|
f0af12f466 | ||
|
|
5bea87f98c | ||
|
|
8617d900ee | ||
|
|
1d91357cad | ||
|
|
1d495cb26e | ||
|
|
31b5a8f16e | ||
|
|
b1c8690872 | ||
|
|
c01470ac5a | ||
|
|
98d3b76e4b | ||
|
|
4a5e4ce85c | ||
|
|
d3a3ab57a1 | ||
|
|
86a79f8cda | ||
|
|
5fdf3127ae | ||
|
|
e9635160d1 | ||
|
|
4e6f99c745 | ||
|
|
1fd72ba8bc | ||
|
|
03e12bf701 | ||
|
|
4ae10dba7b | ||
|
|
cad559f1d9 | ||
|
|
b60c564173 | ||
|
|
7c21431efe | ||
|
|
d345f4ccf8 | ||
|
|
6b70d8c6d0 | ||
|
|
5f8e2b0ac3 | ||
|
|
fa7a8d96ea | ||
|
|
291237d4f5 | ||
|
|
d4e85e3055 | ||
|
|
78992316e5 | ||
|
|
70333e3b5f | ||
|
|
492e1ac11c | ||
|
|
3daf5c48f0 | ||
|
|
e9e250191c | ||
|
|
9f5ccd9ef1 | ||
|
|
3661df2667 | ||
|
|
759ee4afd7 | ||
|
|
375f771e8a | ||
|
|
e6fa0d491d | ||
|
|
fa397dc036 | ||
|
|
c61d652229 | ||
|
|
63b697e25c | ||
|
|
d3fe2f0f41 | ||
|
|
cb50acca09 | ||
|
|
bdce61d160 | ||
|
|
a562f5ec75 | ||
|
|
d21ab708dc | ||
|
|
ba642fb2d3 | ||
|
|
70934a95ad | ||
|
|
50f4a5d8e3 | ||
|
|
d4bacc4afb | ||
|
|
8942971368 | ||
|
|
f801993d0e | ||
|
|
0c551c8276 | ||
|
|
4d189f44a9 | ||
|
|
044af3d50b | ||
|
|
e092c15004 | ||
|
|
03bb6cd8b8 | ||
|
|
b1daf9c6b1 | ||
|
|
317a4b52cc | ||
|
|
033057fb6b | ||
|
|
1c463da5c7 | ||
|
|
f8fcd81057 | ||
|
|
97576dbfb4 | ||
|
|
0bd64b7578 | ||
|
|
b59cde68b0 | ||
|
|
8ea6589ab6 | ||
|
|
79bed774aa | ||
|
|
b919736ce1 | ||
|
|
d9a4f12902 | ||
|
|
6258700bc4 | ||
|
|
a62bdcc51c | ||
|
|
8341c15475 | ||
|
|
ce8c9496de | ||
|
|
779a099c16 | ||
|
|
031c7d215f | ||
|
|
75596fefd2 | ||
|
|
efaeeba5ec | ||
|
|
3161ee0974 | ||
|
|
127fd1bfd1 | ||
|
|
b7be446ecd | ||
|
|
18cb201a14 | ||
|
|
545a0d6532 | ||
|
|
574a4b5c22 | ||
|
|
028bf647e8 | ||
|
|
93b90742ed | ||
|
|
61d754bdd0 | ||
|
|
62568198a1 | ||
|
|
649cba97a6 | ||
|
|
c3adddc86b | ||
|
|
1c96c43c2d | ||
|
|
d1abde68b9 | ||
|
|
52b997a5b4 | ||
|
|
b384da1825 | ||
|
|
ef206baf69 | ||
|
|
5392d42d97 | ||
|
|
2e8cc302d0 | ||
|
|
a59cdd5d16 | ||
|
|
d89e0f1134 | ||
|
|
cae1dcd493 | ||
|
|
ff40776458 | ||
|
|
d7021ba54c | ||
|
|
8fcc011f71 | ||
|
|
6c62229c03 | ||
|
|
9353c14dcf | ||
|
|
299f081919 | ||
|
|
919822300d | ||
|
|
1b136cd4e9 | ||
|
|
2d4a027001 | ||
|
|
ae3e25dbf9 | ||
|
|
189350c55c | ||
|
|
9c8a5ab372 | ||
|
|
e37de9fc3a | ||
|
|
13c69cb30f | ||
|
|
eeb6460548 | ||
|
|
92c8c719eb | ||
|
|
645ed772f4 | ||
|
|
4114cc0078 | ||
|
|
aee4c49015 | ||
|
|
4165c22c2b | ||
|
|
c53a23faf9 | ||
|
|
cd73362c1c | ||
|
|
a730eab331 | ||
|
|
7bc7b898a2 | ||
|
|
83557c3ebe | ||
|
|
58725d95d2 | ||
|
|
7db022f392 | ||
|
|
44bbb25c7e | ||
|
|
004526e685 | ||
|
|
9c7d87ff8a | ||
|
|
642fdc36ee | ||
|
|
c6155443a2 | ||
|
|
420bca5ece | ||
|
|
b96561bf33 | ||
|
|
61c018ee0d | ||
|
|
41e3e0a133 | ||
|
|
3853a469cd | ||
|
|
62b31b8924 | ||
|
|
ab1d0c96f4 | ||
|
|
96354096e2 | ||
|
|
25b686f183 | ||
|
|
7694236434 | ||
|
|
7c0f1da312 | ||
|
|
019d9747b5 | ||
|
|
e806ec223a | ||
|
|
9f58e76f97 | ||
|
|
371750a184 | ||
|
|
2494bda4e8 | ||
|
|
b67be09cd3 | ||
|
|
09b1c9bc3a | ||
|
|
483fb56e25 | ||
|
|
7857fae68f | ||
|
|
61b84cd223 | ||
|
|
bd67332adc | ||
|
|
84657fd6b3 | ||
|
|
f197b45f9e | ||
|
|
d0dc8bec5d | ||
|
|
c9e0408eb2 | ||
|
|
b50a5b2b54 | ||
|
|
c8e3d7404c | ||
|
|
c4c41e324e | ||
|
|
4d324913df | ||
|
|
19bd549b1f | ||
|
|
9dc501c9ab | ||
|
|
5521869ac9 | ||
|
|
c9a712f692 | ||
|
|
6a0b3fc8bd | ||
|
|
684fd99b83 | ||
|
|
fd83daa776 | ||
|
|
9a87716635 | ||
|
|
31566ce64b | ||
|
|
d078879e7b | ||
|
|
5fa42b6ca5 | ||
|
|
e164b87436 | ||
|
|
433d6a0f46 | ||
|
|
cf8b1edbe2 | ||
|
|
500e00a9cc | ||
|
|
c3f80e9c74 | ||
|
|
7eb06e175f | ||
|
|
141681be05 | ||
|
|
dcfab5e001 | ||
|
|
498bbf4528 | ||
|
|
dc72608622 | ||
|
|
a50e0a0830 | ||
|
|
db707dfce1 | ||
|
|
929cc7615e | ||
|
|
bb2e3fbb1b | ||
|
|
7d548aef6c | ||
|
|
7f64236e89 | ||
|
|
199b6f142b | ||
|
|
af87320f11 | ||
|
|
665756d85b | ||
|
|
590006413c | ||
|
|
1b079fa3ff | ||
|
|
cb16db3bf9 | ||
|
|
de6fad41f6 | ||
|
|
d1a43230a9 | ||
|
|
442ef83339 | ||
|
|
1eab544692 | ||
|
|
af0ebeab84 | ||
|
|
0844dc97d2 | ||
|
|
42c10c2729 | ||
|
|
cf568bcbe2 | ||
|
|
36152d8773 | ||
|
|
3f371be097 | ||
|
|
5eba097e38 | ||
|
|
7e868d8d0a | ||
|
|
97b851cbc1 | ||
|
|
397cb9b9bd | ||
|
|
05eb3af243 | ||
|
|
e7a3b72fad | ||
|
|
3e83bc8039 | ||
|
|
c0b5df3f2f | ||
|
|
c13136de52 | ||
|
|
ac8db3a98c | ||
|
|
7554a4888a |
No files matched your search
+100
-12
@@ -7,13 +7,28 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
config:
|
||||||
|
- name: Release
|
||||||
|
lower: release
|
||||||
|
flavor: standard
|
||||||
|
|
||||||
|
- name: Debug
|
||||||
|
lower: debug
|
||||||
|
flavor: standard
|
||||||
|
|
||||||
|
- name: Release
|
||||||
|
lower: release
|
||||||
|
flavor: googlePlay
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Setup Java JDK
|
- name: Setup Java JDK
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||||
with:
|
with:
|
||||||
java-version: 21
|
java-version: 21
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
@@ -25,25 +40,98 @@ jobs:
|
|||||||
- name: Fix gradle permissions
|
- name: Fix gradle permissions
|
||||||
run: chmod +x ./gradlew
|
run: chmod +x ./gradlew
|
||||||
|
|
||||||
- name: Build project
|
- name: Build project (standard)
|
||||||
|
if: ${{ matrix.config.flavor == 'standard' }}
|
||||||
run: |
|
run: |
|
||||||
./gradlew app:assembleDebug \
|
./gradlew app:assembleStandard${{ matrix.config.name }} \
|
||||||
-Pandroid.injected.signing.store.file="$HOME/release.keystore" \
|
-Pandroid.injected.signing.store.file="$HOME/release.keystore" \
|
||||||
-Pandroid.injected.signing.store.password="${{secrets.KEYSTORE_PASSWORD}}" \
|
-Pandroid.injected.signing.store.password="${{secrets.KEYSTORE_PASSWORD}}" \
|
||||||
-Pandroid.injected.signing.key.alias="${{secrets.KEY_ALIAS}}" \
|
-Pandroid.injected.signing.key.alias="${{secrets.KEY_ALIAS}}" \
|
||||||
-Pandroid.injected.signing.key.password="${{secrets.KEY_PASSWORD}}"
|
-Pandroid.injected.signing.key.password="${{secrets.KEY_PASSWORD}}"
|
||||||
|
|
||||||
- name: Rename apk
|
- name: Build project (Google Play)
|
||||||
id: rename_apk
|
if: ${{ matrix.config.flavor == 'googlePlay' }}
|
||||||
|
run: |
|
||||||
|
./gradlew app:bundleGooglePlayRelease -PdisableAbiSplits \
|
||||||
|
-Pandroid.injected.signing.store.file="$HOME/release.keystore" \
|
||||||
|
-Pandroid.injected.signing.store.password="${{secrets.KEYSTORE_PASSWORD}}" \
|
||||||
|
-Pandroid.injected.signing.key.alias="${{secrets.KEY_ALIAS}}" \
|
||||||
|
-Pandroid.injected.signing.key.password="${{secrets.KEY_PASSWORD}}"
|
||||||
|
|
||||||
|
- name: Rename universal apk
|
||||||
|
id: rename_apk_universal
|
||||||
|
if: ${{ matrix.config.flavor == 'standard' }}
|
||||||
run: |
|
run: |
|
||||||
SHORT_SHA=${GITHUB_SHA::7}
|
SHORT_SHA=${GITHUB_SHA::7}
|
||||||
APK_PATH=${{github.workspace}}/app/build/outputs/apk/debug/app-debug.apk
|
APK_PATH=${{github.workspace}}/app/build/outputs/apk/standard/${{ matrix.config.lower }}/app-standard-universal-${{ matrix.config.lower }}.apk
|
||||||
OUT_PATH=${{github.workspace}}/geode-launcher-debug-$SHORT_SHA.apk
|
OUT_PATH=${{github.workspace}}/geode-launcher-${{ matrix.config.lower }}-$SHORT_SHA.apk
|
||||||
mv $APK_PATH $OUT_PATH
|
mv $APK_PATH $OUT_PATH
|
||||||
echo "path=$OUT_PATH" >> $GITHUB_OUTPUT
|
echo "path=$OUT_PATH" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Upload development build
|
- name: Rename apk
|
||||||
uses: actions/upload-artifact@v3
|
id: rename_apk
|
||||||
|
if: ${{ matrix.config.flavor == 'standard' }}
|
||||||
|
run: |
|
||||||
|
SHORT_SHA=${GITHUB_SHA::7}
|
||||||
|
APK_PATH=${{github.workspace}}/app/build/outputs/apk/standard/${{ matrix.config.lower }}/app-standard-armeabi-v7a-${{ matrix.config.lower }}.apk
|
||||||
|
OUT_PATH=${{github.workspace}}/geode-launcher-${{ matrix.config.lower }}-android32-$SHORT_SHA.apk
|
||||||
|
mv $APK_PATH $OUT_PATH
|
||||||
|
echo "path=$OUT_PATH" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Rename mappings
|
||||||
|
id: rename_mappings
|
||||||
|
if: ${{ matrix.config.flavor == 'standard' && matrix.config.lower == 'release' }}
|
||||||
|
run: |
|
||||||
|
SHORT_SHA=${GITHUB_SHA::7}
|
||||||
|
MAPPING_PATH=${{github.workspace}}/app/build/outputs/mapping/standardRelease/mapping.txt
|
||||||
|
OUT_PATH=${{github.workspace}}/mapping-${{ matrix.config.lower }}-$SHORT_SHA.txt
|
||||||
|
mv $MAPPING_PATH $OUT_PATH
|
||||||
|
echo "path=$OUT_PATH" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Rename AAB
|
||||||
|
id: rename_aab
|
||||||
|
if: ${{ matrix.config.flavor == 'googlePlay' }}
|
||||||
|
run: |
|
||||||
|
SHORT_SHA=${GITHUB_SHA::7}
|
||||||
|
AAB_PATH=${{github.workspace}}/app/build/outputs/bundle/googlePlayRelease/app-googlePlay-release.aab
|
||||||
|
OUT_PATH=${{github.workspace}}/geode-launcher-${{ matrix.config.lower }}-$SHORT_SHA.aab
|
||||||
|
mv $AAB_PATH $OUT_PATH
|
||||||
|
echo "path=$OUT_PATH" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Rename AAB mappings
|
||||||
|
id: rename_aab_mappings
|
||||||
|
if: ${{ matrix.config.flavor == 'googlePlay' }}
|
||||||
|
run: |
|
||||||
|
SHORT_SHA=${GITHUB_SHA::7}
|
||||||
|
MAPPING_PATH=${{github.workspace}}/app/build/outputs/mapping/googlePlayRelease/mapping.txt
|
||||||
|
OUT_PATH=${{github.workspace}}/mapping-${{ matrix.config.lower }}-$SHORT_SHA.txt
|
||||||
|
mv $MAPPING_PATH $OUT_PATH
|
||||||
|
echo "path=$OUT_PATH" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Upload release build
|
||||||
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||||
|
if: ${{ matrix.config.flavor == 'standard' && matrix.config.lower == 'release' }}
|
||||||
with:
|
with:
|
||||||
name: geode-launcher-debug
|
name: geode-launcher-${{ matrix.config.lower }}
|
||||||
path: ${{ steps.rename_apk.outputs.path }}
|
path: |
|
||||||
|
${{ steps.rename_apk_universal.outputs.path }}
|
||||||
|
${{ steps.rename_apk.outputs.path }}
|
||||||
|
${{ steps.rename_mappings.outputs.path }}
|
||||||
|
|
||||||
|
- name: Upload debug build
|
||||||
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||||
|
if: ${{ matrix.config.flavor == 'standard' && matrix.config.lower == 'debug' }}
|
||||||
|
with:
|
||||||
|
name: geode-launcher-${{ matrix.config.lower }}
|
||||||
|
path: |
|
||||||
|
${{ steps.rename_apk_universal.outputs.path }}
|
||||||
|
${{ steps.rename_apk.outputs.path }}
|
||||||
|
|
||||||
|
- name: Upload Google Play build
|
||||||
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||||
|
if: ${{ matrix.config.flavor == 'googlePlay' }}
|
||||||
|
with:
|
||||||
|
name: geode-launcher-google-play
|
||||||
|
path: |
|
||||||
|
${{ steps.rename_aab.outputs.path }}
|
||||||
|
${{ steps.rename_aab_mappings.outputs.path }}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
name: Update Website
|
||||||
|
|
||||||
|
on:
|
||||||
|
# release:
|
||||||
|
# types:
|
||||||
|
# - released
|
||||||
|
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
trigger:
|
||||||
|
name: Trigger site build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GEODE_BOT_PUSH_BIN_TOKEN }}
|
||||||
|
script: |
|
||||||
|
await github.rest.actions.createWorkflowDispatch({
|
||||||
|
owner: 'geode-sdk',
|
||||||
|
repo: 'website',
|
||||||
|
workflow_id: 'build.yml',
|
||||||
|
ref: 'main'
|
||||||
|
})
|
||||||
+2
-1
@@ -9,4 +9,5 @@
|
|||||||
.cxx
|
.cxx
|
||||||
local.properties
|
local.properties
|
||||||
app/release/
|
app/release/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.kotlin/
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
Boost Software License - Version 1.0 - August 17th, 2003
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person or organization
|
||||||
|
obtaining a copy of the software and accompanying documentation covered by
|
||||||
|
this license (the "Software") to use, reproduce, display, distribute,
|
||||||
|
execute, and transmit the Software, and to prepare derivative works of the
|
||||||
|
Software, and to permit third-parties to whom the Software is furnished to
|
||||||
|
do so, all subject to the following:
|
||||||
|
|
||||||
|
The copyright notices in the Software and this entire statement, including
|
||||||
|
the above license grant, this restriction and the following disclaimer,
|
||||||
|
must be included in all copies of the Software, in whole or in part, and
|
||||||
|
all derivative works of the Software, unless such copies or derivative
|
||||||
|
works are solely in the form of machine-executable object code generated by
|
||||||
|
a source language processor.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT
|
||||||
|
SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE
|
||||||
|
FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE,
|
||||||
|
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||||
|
DEALINGS IN THE SOFTWARE.
|
||||||
@@ -2,6 +2,17 @@
|
|||||||
|
|
||||||
Launches a vanilla copy of Geometry Dash with the Geode loader added.
|
Launches a vanilla copy of Geometry Dash with the Geode loader added.
|
||||||
|
|
||||||
## TODO:
|
## Shortcut Links
|
||||||
|
|
||||||
* Figure out better way of packaging libgeode
|
The launcher supports a handful of links as an escape hatch when the main functionality is impaired in some way (such as needing to prevent an automatic launch due to a crash).
|
||||||
|
You can open these links by copying them into the URL bar in some browsers (like Chrome), or through ADB.
|
||||||
|
|
||||||
|
| Link | Action |
|
||||||
|
|---------------------------------------------|--------------------------------------------------------------------------|
|
||||||
|
| geode-launcher://main | Opens to the main activity, forces the user to explicitly confirm launch |
|
||||||
|
| geode-launcher://main/launch | Opens to the game after the launcher is finished checking for updates |
|
||||||
|
| geode-launcher://main/launch?safe-mode=true | Opens to the game like before, but with safe mode enabled |
|
||||||
|
| geode-launcher://settings | Opens the main settings |
|
||||||
|
| geode-launcher://developer-settings | Opens the developer settings |
|
||||||
|
| geode-launcher://logs | Opens the application logs |
|
||||||
|
| geode-launcher://crashes | Opens the crash dumps viewer |
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
plugins {
|
|
||||||
id 'com.android.application'
|
|
||||||
id 'org.jetbrains.kotlin.android'
|
|
||||||
id 'org.jetbrains.kotlin.plugin.serialization'
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
|
||||||
compileSdk 34
|
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
applicationId "com.geode.launcher"
|
|
||||||
minSdk 23
|
|
||||||
targetSdk 34
|
|
||||||
versionCode 4
|
|
||||||
versionName "2.0.0-beta.1"
|
|
||||||
|
|
||||||
vectorDrawables {
|
|
||||||
useSupportLibrary true
|
|
||||||
}
|
|
||||||
externalNativeBuild {
|
|
||||||
cmake {
|
|
||||||
arguments "-DUSE_TULIPHOOK:BOOL=OFF"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ndkConfig.abiFilters "arm64-v8a", "armeabi-v7a"
|
|
||||||
}
|
|
||||||
|
|
||||||
buildTypes {
|
|
||||||
release {
|
|
||||||
minifyEnabled false
|
|
||||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
compileOptions {
|
|
||||||
// enables a polyfill for java Instant on api levels < 26 (used for updater)
|
|
||||||
coreLibraryDesugaringEnabled true
|
|
||||||
|
|
||||||
sourceCompatibility JavaVersion.VERSION_1_8
|
|
||||||
targetCompatibility JavaVersion.VERSION_1_8
|
|
||||||
}
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = '1.8'
|
|
||||||
}
|
|
||||||
buildFeatures {
|
|
||||||
compose true
|
|
||||||
}
|
|
||||||
composeOptions {
|
|
||||||
kotlinCompilerExtensionVersion compose_compiler
|
|
||||||
}
|
|
||||||
packagingOptions {
|
|
||||||
resources {
|
|
||||||
excludes += '/META-INF/{AL2.0,LGPL2.1}'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
externalNativeBuild {
|
|
||||||
cmake {
|
|
||||||
path file('src/main/cpp/CMakeLists.txt')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
namespace 'com.geode.launcher'
|
|
||||||
ndkVersion '26.1.10909125'
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation 'androidx.core:core-ktx:1.12.0'
|
|
||||||
implementation "androidx.compose.ui:ui:$compose_version"
|
|
||||||
implementation 'androidx.compose.material3:material3:1.1.2'
|
|
||||||
implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
|
|
||||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
|
|
||||||
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0"
|
|
||||||
implementation 'androidx.activity:activity-compose:1.8.2'
|
|
||||||
implementation 'androidx.activity:activity-ktx:1.8.2'
|
|
||||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
|
||||||
implementation "androidx.documentfile:documentfile:1.0.1"
|
|
||||||
implementation "com.squareup.okio:okio:3.7.0"
|
|
||||||
implementation "com.squareup.okhttp3:okhttp:4.12.0"
|
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0'
|
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-datetime:0.5.0'
|
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json-okio:1.6.0"
|
|
||||||
debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
|
|
||||||
debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version"
|
|
||||||
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.0.4"
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
import groovy.json.JsonSlurper
|
||||||
|
import java.net.URI
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.util.zip.ZipInputStream
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
extraWarnings.set(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.plugin.serialization")
|
||||||
|
id("org.jetbrains.kotlin.plugin.compose")
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentGeodeVersion = "5.11.2"
|
||||||
|
|
||||||
|
abstract class DownloadVersionMetadataTask : DefaultTask() {
|
||||||
|
@get:Input
|
||||||
|
abstract val geodeVersion: Property<String>
|
||||||
|
|
||||||
|
@get:OutputFile
|
||||||
|
abstract val targetFile: RegularFileProperty
|
||||||
|
|
||||||
|
@TaskAction
|
||||||
|
fun execute() {
|
||||||
|
val downloadGeodeVersion = geodeVersion.get()
|
||||||
|
val destinationFile = targetFile.get().asFile
|
||||||
|
|
||||||
|
val releaseUrl = "https://api.geode-sdk.org/v1/loader/versions/$downloadGeodeVersion"
|
||||||
|
URI.create(releaseUrl).toURL().openStream().use { response ->
|
||||||
|
destinationFile.outputStream().use { destination ->
|
||||||
|
response.copyTo(destination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class DownloadGeodeFileTask : DefaultTask() {
|
||||||
|
@get:Input
|
||||||
|
abstract val platform: Property<String>
|
||||||
|
|
||||||
|
@get:InputFile
|
||||||
|
abstract val metadata: RegularFileProperty
|
||||||
|
|
||||||
|
private fun downloadAndVerifyFile(filename: String, url: String, hash: String?): File {
|
||||||
|
logger.info("Downloading $filename from $url")
|
||||||
|
|
||||||
|
val tempFile = File(temporaryDir, filename)
|
||||||
|
|
||||||
|
URI.create(url).toURL().openStream().use { response ->
|
||||||
|
tempFile.outputStream().use { output ->
|
||||||
|
response.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hash != null) {
|
||||||
|
val downloadedHash = MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(tempFile.readBytes())
|
||||||
|
.toHexString()
|
||||||
|
|
||||||
|
if (downloadedHash != hash) {
|
||||||
|
throw GradleException("Hash check failed for file $filename, expected $hash but found $downloadedHash.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tempFile
|
||||||
|
}
|
||||||
|
|
||||||
|
fun downloadZip(fallbackUrl: String?): File {
|
||||||
|
val downloadPlatform = platform.get()
|
||||||
|
|
||||||
|
val metadataFile = metadata.get().asFile
|
||||||
|
val json = JsonSlurper().parse(metadataFile) as Map<*, *>
|
||||||
|
|
||||||
|
val payload = json["payload"] as? Map<*, *>
|
||||||
|
|
||||||
|
val downloads = payload?.get("downloads") as? Map<*, *>
|
||||||
|
val platformInfo = downloads?.get(downloadPlatform) as? Map<*, *>
|
||||||
|
|
||||||
|
val downloadUrl = platformInfo?.get("url") as? String ?: fallbackUrl
|
||||||
|
if (downloadUrl == null) {
|
||||||
|
throw GradleException("Failed to fetch download url for platform $platform.")
|
||||||
|
}
|
||||||
|
|
||||||
|
val expectedHash = platformInfo?.get("hash") as? String
|
||||||
|
|
||||||
|
return downloadAndVerifyFile("$downloadPlatform.zip", downloadUrl, expectedHash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class DownloadAndUnzipGeodeTask : DownloadGeodeFileTask() {
|
||||||
|
@get:Input
|
||||||
|
abstract val geodeVersion: Property<String>
|
||||||
|
|
||||||
|
@get:OutputFile
|
||||||
|
abstract val targetFile: RegularFileProperty
|
||||||
|
|
||||||
|
@TaskAction
|
||||||
|
fun execute() {
|
||||||
|
val downloadGeodeVersion = geodeVersion.get()
|
||||||
|
val downloadPlatform = platform.get()
|
||||||
|
val destinationFile = targetFile.get().asFile
|
||||||
|
|
||||||
|
val tempOutput = this.downloadZip("https://github.com/geode-sdk/geode/releases/download/v$downloadGeodeVersion/geode-v$downloadGeodeVersion-$downloadPlatform.zip")
|
||||||
|
|
||||||
|
val expectedFile = "Geode.$downloadPlatform.so"
|
||||||
|
|
||||||
|
ZipInputStream(tempOutput.inputStream()).use { zip ->
|
||||||
|
var entry = zip.nextEntry
|
||||||
|
while (entry != null) {
|
||||||
|
if (entry.name == expectedFile) {
|
||||||
|
destinationFile.outputStream().use {
|
||||||
|
zip.copyTo(it)
|
||||||
|
}
|
||||||
|
return@execute
|
||||||
|
}
|
||||||
|
|
||||||
|
entry = zip.nextEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
throw GradleException("Could not find Geode release.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class DownloadAndUnzipGeodeResourcesTask : DownloadGeodeFileTask() {
|
||||||
|
init {
|
||||||
|
platform.set("resources")
|
||||||
|
}
|
||||||
|
|
||||||
|
@get:Input
|
||||||
|
abstract val geodeVersion: Property<String>
|
||||||
|
|
||||||
|
@get:OutputDirectory
|
||||||
|
abstract val outputDir: DirectoryProperty
|
||||||
|
|
||||||
|
@TaskAction
|
||||||
|
fun execute() {
|
||||||
|
val downloadGeodeVersion = geodeVersion.get()
|
||||||
|
|
||||||
|
val tempOutput = this.downloadZip("https://github.com/geode-sdk/geode/releases/download/v$downloadGeodeVersion/resources.zip")
|
||||||
|
|
||||||
|
val baseOutput = outputDir.get().asFile
|
||||||
|
if (baseOutput.exists()) baseOutput.deleteRecursively()
|
||||||
|
|
||||||
|
val output = File(baseOutput, "geode.loader")
|
||||||
|
output.mkdirs()
|
||||||
|
|
||||||
|
val canonicalOutput = output.canonicalPath
|
||||||
|
|
||||||
|
ZipInputStream(tempOutput.inputStream()).use { zipStream ->
|
||||||
|
var entry = zipStream.nextEntry
|
||||||
|
while (entry != null) {
|
||||||
|
val destination = File(output, entry.name)
|
||||||
|
if (!destination.canonicalPath.startsWith(canonicalOutput)) {
|
||||||
|
throw GradleException("attempted copy to outside of output directory: ${entry.name}")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (destination.isDirectory) {
|
||||||
|
destination.mkdirs()
|
||||||
|
} else {
|
||||||
|
destination.parentFile?.mkdirs()
|
||||||
|
|
||||||
|
destination.outputStream().use { destinationStream ->
|
||||||
|
zipStream.copyTo(destinationStream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry = zipStream.nextEntry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val fetchGeodeMetadataTask = tasks.register<DownloadVersionMetadataTask>("fetchGeodeMetadata") {
|
||||||
|
description = "Fetches and saves a Geode version's metadata"
|
||||||
|
inputs.property("geodeVersion", currentGeodeVersion)
|
||||||
|
|
||||||
|
geodeVersion = currentGeodeVersion
|
||||||
|
targetFile = temporaryDir.resolve("metadata.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
val downloadGeode32Task = tasks.register<DownloadAndUnzipGeodeTask>("downloadGeode32Binary") {
|
||||||
|
description = "Downloads the 32bit Geode release archive"
|
||||||
|
inputs.property("geodeVersion", currentGeodeVersion)
|
||||||
|
|
||||||
|
geodeVersion = currentGeodeVersion
|
||||||
|
platform = "android32"
|
||||||
|
metadata = fetchGeodeMetadataTask.flatMap { it.targetFile }
|
||||||
|
targetFile = temporaryDir.resolve("libgeode.so")
|
||||||
|
}
|
||||||
|
|
||||||
|
val downloadGeode64Task = tasks.register<DownloadAndUnzipGeodeTask>("downloadGeode64Binary") {
|
||||||
|
description = "Downloads the 64bit Geode release archive"
|
||||||
|
inputs.property("geodeVersion", currentGeodeVersion)
|
||||||
|
|
||||||
|
geodeVersion = currentGeodeVersion
|
||||||
|
platform = "android64"
|
||||||
|
metadata = fetchGeodeMetadataTask.flatMap { it.targetFile }
|
||||||
|
targetFile = temporaryDir.resolve("libgeode.so")
|
||||||
|
}
|
||||||
|
|
||||||
|
val downloadGeodeResourcesTask = tasks.register<DownloadAndUnzipGeodeResourcesTask>("downloadGeodeResources") {
|
||||||
|
description = "Downloads Geode resources"
|
||||||
|
inputs.property("geodeVersion", currentGeodeVersion)
|
||||||
|
|
||||||
|
geodeVersion = currentGeodeVersion
|
||||||
|
metadata = fetchGeodeMetadataTask.flatMap { it.targetFile }
|
||||||
|
outputDir = layout.buildDirectory.dir("geode-resources")
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class MergeGeodeFilesTask : DefaultTask() {
|
||||||
|
@get:InputFile
|
||||||
|
abstract val binary32: RegularFileProperty
|
||||||
|
|
||||||
|
@get:InputFile
|
||||||
|
abstract val binary64: RegularFileProperty
|
||||||
|
|
||||||
|
@get:OutputDirectory
|
||||||
|
abstract val outputDir: DirectoryProperty
|
||||||
|
|
||||||
|
@TaskAction
|
||||||
|
fun execute() {
|
||||||
|
val out = outputDir.get().asFile
|
||||||
|
out.deleteRecursively()
|
||||||
|
out.mkdirs()
|
||||||
|
|
||||||
|
binary32.get().asFile.copyTo(outputDir.file("armeabi-v7a/libgeode.so").get().asFile)
|
||||||
|
binary64.get().asFile.copyTo(outputDir.file("arm64-v8a/libgeode.so").get().asFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val fullDownloadTask = tasks.register<MergeGeodeFilesTask>("downloadGeodeFull") {
|
||||||
|
description = "Downloads both Geode releases"
|
||||||
|
|
||||||
|
binary32 = downloadGeode32Task.flatMap { it.targetFile }
|
||||||
|
binary64 = downloadGeode64Task.flatMap { it.targetFile }
|
||||||
|
outputDir = layout.buildDirectory.dir("geode-prebuilt")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileSdk = 37
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.geode.launcher"
|
||||||
|
minSdk = 23
|
||||||
|
targetSdk = 37
|
||||||
|
versionCode = 31
|
||||||
|
versionName = "1.9.0"
|
||||||
|
|
||||||
|
vectorDrawables {
|
||||||
|
useSupportLibrary = true
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UnstableApiUsage")
|
||||||
|
externalNativeBuild {
|
||||||
|
cmake {
|
||||||
|
arguments("-DANDROID_STL=c++_shared")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//noinspection ChromeOsAbiSupport (not my fault)
|
||||||
|
ndk.abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||||
|
}
|
||||||
|
|
||||||
|
splits {
|
||||||
|
abi {
|
||||||
|
isEnable = !project.hasProperty("disableAbiSplits")
|
||||||
|
reset()
|
||||||
|
|
||||||
|
//noinspection ChromeOsAbiSupport. i'm sorry!
|
||||||
|
include("arm64-v8a", "armeabi-v7a")
|
||||||
|
|
||||||
|
isUniversalApk = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flavorDimensions += "distribution"
|
||||||
|
|
||||||
|
productFlavors {
|
||||||
|
create("standard") {
|
||||||
|
dimension = "distribution"
|
||||||
|
isDefault = true
|
||||||
|
buildConfigField("boolean", "GOOGLE_PLAY_BUILD", "false")
|
||||||
|
buildConfigField("String", "PREBUNDLED_GEODE", "null")
|
||||||
|
}
|
||||||
|
|
||||||
|
create("googlePlay") {
|
||||||
|
dimension = "distribution"
|
||||||
|
applicationId = "com.geode.launcher.play"
|
||||||
|
buildConfigField("boolean", "GOOGLE_PLAY_BUILD", "true")
|
||||||
|
buildConfigField("String", "PREBUNDLED_GEODE", "\"$currentGeodeVersion\"")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = true
|
||||||
|
isShrinkResources = true
|
||||||
|
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
// enables a polyfill for java Instant on api levels < 26 (used for updater)
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
|
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
packaging {
|
||||||
|
resources {
|
||||||
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
externalNativeBuild {
|
||||||
|
cmake {
|
||||||
|
version = "3.21.0+"
|
||||||
|
path = file("src/main/cpp/CMakeLists.txt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
namespace = "com.geode.launcher"
|
||||||
|
ndkVersion = "30.0.16248370"
|
||||||
|
}
|
||||||
|
|
||||||
|
androidComponents {
|
||||||
|
onVariants(selector().withFlavor("distribution" to "googlePlay")) { variant ->
|
||||||
|
variant.sources.jniLibs?.addGeneratedSourceDirectory(
|
||||||
|
fullDownloadTask,
|
||||||
|
MergeGeodeFilesTask::outputDir
|
||||||
|
)
|
||||||
|
variant.sources.assets?.addGeneratedSourceDirectory(
|
||||||
|
downloadGeodeResourcesTask,
|
||||||
|
DownloadAndUnzipGeodeResourcesTask::outputDir
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation (platform("androidx.compose:compose-bom:2026.09.00"))
|
||||||
|
implementation ("androidx.core:core-ktx:1.19.1")
|
||||||
|
implementation ("androidx.compose.ui:ui")
|
||||||
|
implementation ("androidx.compose.material3:material3")
|
||||||
|
implementation ("androidx.compose.ui:ui-tooling-preview")
|
||||||
|
implementation ("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0")
|
||||||
|
implementation ("androidx.lifecycle:lifecycle-viewmodel-compose:2.11.0")
|
||||||
|
implementation ("androidx.activity:activity-compose:1.13.0")
|
||||||
|
implementation ("androidx.activity:activity-ktx:1.13.0")
|
||||||
|
implementation ("androidx.appcompat:appcompat:1.8.0")
|
||||||
|
implementation ("androidx.documentfile:documentfile:1.1.0")
|
||||||
|
|
||||||
|
implementation ("com.squareup.okio:okio:3.18.2")
|
||||||
|
implementation ("com.squareup.okhttp3:okhttp:5.5.0")
|
||||||
|
implementation ("com.squareup.okhttp3:okhttp-coroutines:5.5.0")
|
||||||
|
|
||||||
|
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
|
||||||
|
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json-okio:1.11.0")
|
||||||
|
implementation ("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
|
||||||
|
|
||||||
|
implementation ("com.mikepenz:multiplatform-markdown-renderer-android:0.46.0")
|
||||||
|
implementation ("com.mikepenz:multiplatform-markdown-renderer-m3:0.46.0")
|
||||||
|
implementation ("androidx.browser:browser:1.10.0")
|
||||||
|
debugImplementation ("androidx.compose.ui:ui-tooling")
|
||||||
|
coreLibraryDesugaring ("com.android.tools:desugar_jdk_libs:2.1.5")
|
||||||
|
}
|
||||||
Vendored
+6
-20
@@ -1,21 +1,7 @@
|
|||||||
# Add project specific ProGuard rules here.
|
# Add wildcard keep rules to all of the library code in use
|
||||||
# You can control the set of applied configuration files using the
|
-keep class org.fmod.** { *; }
|
||||||
# proguardFiles setting in build.gradle.
|
-keep class org.cocos2dx.lib.** { *; }
|
||||||
#
|
-keep class com.customRobTop.** { *; }
|
||||||
# For more details, see
|
|
||||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
|
||||||
|
|
||||||
# If your project uses WebView with JS, uncomment the following
|
# note: if you're going to add more rules, consider the @Keep annotation
|
||||||
# and specify the fully qualified class name to the JavaScript interface
|
# this should really only be kept to non-custom code
|
||||||
# class:
|
|
||||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
|
||||||
# public *;
|
|
||||||
#}
|
|
||||||
|
|
||||||
# Uncomment this to preserve the line number information for
|
|
||||||
# debugging stack traces.
|
|
||||||
#-keepattributes SourceFile,LineNumberTable
|
|
||||||
|
|
||||||
# If you keep the line number information, uncomment this to
|
|
||||||
# hide the original source file name.
|
|
||||||
#-renamesourcefileattribute SourceFile
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.REQUEST_INSTALL_PACKAGES"
|
||||||
|
tools:node="remove" />
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -9,9 +9,18 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
<uses-permission
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
<uses-permission android:name="android.permission.READ_LOGS" />
|
android:maxSdkVersion="32" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="32" />
|
||||||
|
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
|
<uses-permission android:name="android.permission.LIGHTS" />
|
||||||
|
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.hardware.wifi"
|
android:name="android.hardware.wifi"
|
||||||
@@ -19,26 +28,104 @@
|
|||||||
<uses-feature
|
<uses-feature
|
||||||
android:glEsVersion="0x00020000"
|
android:glEsVersion="0x00020000"
|
||||||
android:required="true" />
|
android:required="true" />
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.hardware.gamepad"
|
||||||
|
android:required="false" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
android:appCategory="game"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:enableOnBackInvokedCallback="true"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
|
android:hasFragileUserData="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
android:requestLegacyExternalStorage="true"
|
||||||
android:theme="@style/Theme.GeodeLauncher"
|
android:theme="@style/Theme.GeodeLauncher"
|
||||||
tools:targetApi="31">
|
tools:targetApi="35">
|
||||||
|
<receiver
|
||||||
|
android:name=".InstallReceiver"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".SettingsActivity"
|
android:name=".preferences.DeveloperSettingsActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/title_activity_developer_settings"
|
||||||
|
android:theme="@style/Theme.GeodeLauncher">
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="geode-launcher" android:host="developer-settings" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<activity
|
||||||
|
android:name=".preferences.ApplicationLogsActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/title_activity_application_logs"
|
||||||
|
android:theme="@style/Theme.GeodeLauncher">
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="geode-launcher" android:host="logs" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<activity
|
||||||
|
android:name=".preferences.CrashLogsActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/title_activity_crash_logs"
|
||||||
|
android:theme="@style/Theme.GeodeLauncher">
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="geode-launcher" android:host="crashes" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<activity
|
||||||
|
android:name=".preferences.GeodeLogsListingActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/title_activity_geode_logs"
|
||||||
|
android:theme="@style/Theme.GeodeLauncher">
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="geode-launcher" android:host="geode-logs" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<activity
|
||||||
|
android:name=".preferences.TextViewActivity"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
|
android:theme="@style/Theme.GeodeLauncher">
|
||||||
|
</activity>
|
||||||
|
<activity
|
||||||
|
android:name=".preferences.ApplicationIconActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:label="@string/title_activity_application_icon"
|
||||||
|
android:theme="@style/Theme.GeodeLauncher" />
|
||||||
|
<activity
|
||||||
|
android:name=".preferences.SettingsActivity"
|
||||||
|
android:exported="true"
|
||||||
android:label="@string/title_activity_settings"
|
android:label="@string/title_activity_settings"
|
||||||
android:theme="@style/Theme.GeodeLauncher">
|
android:theme="@style/Theme.GeodeLauncher">
|
||||||
<meta-data
|
|
||||||
android:name="android.app.lib_name"
|
<intent-filter>
|
||||||
android:value="" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="geode-launcher" android:host="settings" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<activity
|
<activity
|
||||||
android:name=".GeometryDashActivity"
|
android:name=".GeometryDashActivity"
|
||||||
|
android:configChanges="fontScale|layoutDirection|density|smallestScreenSize|screenSize|uiMode|screenLayout|orientation|navigation|keyboardHidden|keyboard|locale"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
android:screenOrientation="landscape"
|
android:screenOrientation="landscape"
|
||||||
@@ -57,12 +144,121 @@
|
|||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="geode-launcher" android:host="main" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<provider
|
|
||||||
android:authorities="com.geode.launcher.user"
|
<activity-alias
|
||||||
android:name=".UserDirectoryProvider"
|
android:name=".MainActivityTrans"
|
||||||
android:grantUriPermissions="true"
|
android:enabled="false"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
android:icon="@mipmap/ic_launcher_trans"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:targetActivity=".MainActivity">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="geode-launcher" android:host="main" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity-alias>
|
||||||
|
<activity-alias
|
||||||
|
android:name=".MainActivityPride"
|
||||||
|
android:enabled="false"
|
||||||
|
android:exported="true"
|
||||||
|
android:icon="@mipmap/ic_launcher_pride"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:targetActivity=".MainActivity">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="geode-launcher" android:host="main" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity-alias>
|
||||||
|
<activity-alias
|
||||||
|
android:name=".MainActivitySapphire"
|
||||||
|
android:enabled="false"
|
||||||
|
android:exported="true"
|
||||||
|
android:icon="@mipmap/ic_launcher_sapphire"
|
||||||
|
android:label="@string/application_icon_sapphire"
|
||||||
|
android:targetActivity=".MainActivity">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="geode-launcher" android:host="main" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity-alias>
|
||||||
|
<activity-alias
|
||||||
|
android:name=".MainActivityGeode"
|
||||||
|
android:enabled="false"
|
||||||
|
android:exported="true"
|
||||||
|
android:icon="@mipmap/ic_launcher_geode"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:targetActivity=".MainActivity">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="geode-launcher" android:host="main" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity-alias>
|
||||||
|
<activity-alias
|
||||||
|
android:name=".MainActivityGreen"
|
||||||
|
android:enabled="false"
|
||||||
|
android:exported="true"
|
||||||
|
android:icon="@mipmap/ic_launcher_green"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:targetActivity=".MainActivity">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="geode-launcher" android:host="main" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity-alias>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name=".UserDirectoryProvider"
|
||||||
|
android:authorities="${applicationId}.user"
|
||||||
|
android:exported="true"
|
||||||
|
android:grantUriPermissions="true"
|
||||||
android:permission="android.permission.MANAGE_DOCUMENTS">
|
android:permission="android.permission.MANAGE_DOCUMENTS">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
|
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
|
||||||
@@ -70,12 +266,17 @@
|
|||||||
</provider>
|
</provider>
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="com.geode.launcher.fileprovider"
|
android:authorities="${applicationId}.fileprovider"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:grantUriPermissions="true">
|
android:grantUriPermissions="true">
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
android:resource="@xml/provider_paths" />
|
android:resource="@xml/provider_paths" />
|
||||||
</provider>
|
</provider>
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="android.game_mode_config"
|
||||||
|
android:resource="@xml/game_mode_config" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -1,46 +1,17 @@
|
|||||||
cmake_minimum_required(VERSION 3.18.1)
|
cmake_minimum_required(VERSION 3.21)
|
||||||
|
|
||||||
project(launcherfix)
|
project(launcherfix)
|
||||||
|
|
||||||
add_library(launcherfix SHARED launcher-fix.cpp)
|
add_library(launcherfix SHARED launcher-fix.cpp patcher.cpp)
|
||||||
|
|
||||||
target_compile_features(launcherfix PUBLIC cxx_std_20)
|
target_compile_features(launcherfix PUBLIC cxx_std_20)
|
||||||
set_target_properties(launcherfix PROPERTIES CXX_EXTENSIONS OFF)
|
set_target_properties(launcherfix PROPERTIES CXX_EXTENSIONS OFF)
|
||||||
|
|
||||||
include(FetchContent)
|
set_target_properties(launcherfix PROPERTIES
|
||||||
|
INTERPROCEDURAL_OPTIMIZATION ON
|
||||||
option(USE_TULIPHOOK "enables choice of hooking library" OFF)
|
UNITY_BUILD ON
|
||||||
if (USE_TULIPHOOK)
|
C_VISIBILITY_PRESET hidden
|
||||||
FetchContent_Declare(TulipHook
|
CXX_VISIBILITY_PRESET hidden
|
||||||
GIT_REPOSITORY https://github.com/geode-sdk/TulipHook.git
|
)
|
||||||
GIT_TAG 70122be72da7787dc42813ba0a992f3612e7871e
|
|
||||||
)
|
|
||||||
|
|
||||||
set(TULIP_LINK_SOURCE ON CACHE INTERNAL "")
|
|
||||||
FetchContent_MakeAvailable(TulipHook)
|
|
||||||
|
|
||||||
target_compile_definitions(launcherfix PRIVATE -DUSE_TULIPHOOK=1)
|
|
||||||
|
|
||||||
target_link_libraries(launcherfix PRIVATE TulipHook TulipHookInclude)
|
|
||||||
else()
|
|
||||||
FetchContent_Declare(dobby
|
|
||||||
GIT_REPOSITORY https://github.com/jmpews/Dobby
|
|
||||||
GIT_TAG 6a80a61ea062424d1595613058a174c64454fa57
|
|
||||||
)
|
|
||||||
|
|
||||||
FetchContent_GetProperties(dobby)
|
|
||||||
if(NOT dobby_POPULATED)
|
|
||||||
FetchContent_Populate(dobby)
|
|
||||||
|
|
||||||
# fixes Dobby build
|
|
||||||
# add_compile_definitions(typeof=__typeof__)
|
|
||||||
set(DOBBY_DEBUG OFF CACHE INTERNAL "" FORCE)
|
|
||||||
set(DOBBY_GENERATE_SHARED OFF CACHE INTERNAL "" FORCE)
|
|
||||||
|
|
||||||
add_subdirectory("${dobby_SOURCE_DIR}" ${dobby_BINARY_DIR} EXCLUDE_FROM_ALL)
|
|
||||||
target_include_directories(launcherfix PRIVATE ${dobby_SOURCE_DIR}/include)
|
|
||||||
target_link_libraries(launcherfix PRIVATE dobby)
|
|
||||||
endif()
|
|
||||||
endif()
|
|
||||||
|
|
||||||
target_link_libraries(launcherfix PRIVATE log)
|
target_link_libraries(launcherfix PRIVATE log)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <array>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
#include <link.h>
|
||||||
|
|
||||||
|
class DataPaths {
|
||||||
|
public:
|
||||||
|
static std::string original_data_path;
|
||||||
|
static std::string data_path;
|
||||||
|
|
||||||
|
static bool patch_exceptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
// this is every function that i thought would be relevant
|
||||||
|
constexpr std::array<const std::string_view, 10> remove_symbols{
|
||||||
|
"__gxx_personality_v0",
|
||||||
|
"__cxa_throw",
|
||||||
|
"__cxa_rethrow",
|
||||||
|
"__cxa_allocate_exception",
|
||||||
|
"__cxa_end_catch",
|
||||||
|
"__cxa_begin_catch",
|
||||||
|
"__cxa_guard_abort",
|
||||||
|
"__cxa_guard_acquire",
|
||||||
|
"__cxa_guard_release",
|
||||||
|
"__cxa_free_exception"
|
||||||
|
// conflicts with fix within geode on some devices
|
||||||
|
// "__emutls_get_address", // added in 2.208
|
||||||
|
// "__cxa_thread_atexit"
|
||||||
|
|
||||||
|
// android 10's libc doesn't have these symbols
|
||||||
|
// and the ndk is forbidden from exporting them!! so keep it for old mods...
|
||||||
|
// "_Unwind_RaiseException",
|
||||||
|
// "_Unwind_Resume"
|
||||||
|
};
|
||||||
|
|
||||||
|
FILE* fopen_hook(const char* pathname, const char* mode);
|
||||||
|
int rename_hook(const char* old_path, const char* new_path);
|
||||||
|
|
||||||
|
int on_dl_iterate(dl_phdr_info* info, size_t size, void* data);
|
||||||
@@ -1,31 +1,13 @@
|
|||||||
#include <dlfcn.h>
|
|
||||||
#include <jni.h>
|
#include <jni.h>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <android/log.h>
|
|
||||||
|
|
||||||
#ifndef DISABLE_LAUNCHER_FIX
|
#include "base.h"
|
||||||
|
#include "log.hpp"
|
||||||
|
|
||||||
#ifdef USE_TULIPHOOK
|
std::string DataPaths::original_data_path{};
|
||||||
#include <tulip/TulipHook.hpp>
|
std::string DataPaths::data_path{};
|
||||||
#else
|
|
||||||
#include <dobby.h>
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#endif
|
bool DataPaths::patch_exceptions{};
|
||||||
|
|
||||||
class DataPaths {
|
|
||||||
public:
|
|
||||||
std::string original_data_path;
|
|
||||||
std::string data_path;
|
|
||||||
|
|
||||||
static DataPaths& get_instance() {
|
|
||||||
static auto paths_instance = DataPaths();
|
|
||||||
return paths_instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
private:
|
|
||||||
DataPaths() : original_data_path(), data_path() {}
|
|
||||||
};
|
|
||||||
|
|
||||||
extern "C"
|
extern "C"
|
||||||
JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setDataPath(
|
JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setDataPath(
|
||||||
@@ -36,7 +18,7 @@ JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setDataPath(
|
|||||||
auto is_copy = jboolean();
|
auto is_copy = jboolean();
|
||||||
auto data_path_str = env->GetStringUTFChars(data_path, &is_copy);
|
auto data_path_str = env->GetStringUTFChars(data_path, &is_copy);
|
||||||
|
|
||||||
DataPaths::get_instance().data_path = std::string(data_path_str);
|
DataPaths::data_path = std::string(data_path_str);
|
||||||
|
|
||||||
env->ReleaseStringUTFChars(data_path, data_path_str);
|
env->ReleaseStringUTFChars(data_path, data_path_str);
|
||||||
}
|
}
|
||||||
@@ -50,96 +32,58 @@ JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setOriginalDataPath(
|
|||||||
auto is_copy = jboolean();
|
auto is_copy = jboolean();
|
||||||
auto data_path_str = env->GetStringUTFChars(data_path, &is_copy);
|
auto data_path_str = env->GetStringUTFChars(data_path, &is_copy);
|
||||||
|
|
||||||
DataPaths::get_instance().original_data_path = std::string(data_path_str);
|
DataPaths::original_data_path = std::string(data_path_str);
|
||||||
|
|
||||||
env->ReleaseStringUTFChars(data_path, data_path_str);
|
env->ReleaseStringUTFChars(data_path, data_path_str);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string redirect_path(const char* pathname) {
|
extern "C"
|
||||||
auto data_path = DataPaths::get_instance().data_path;
|
JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_enableExceptionsRenaming(JNIEnv*, jobject) {
|
||||||
auto original_data_path = DataPaths::get_instance().original_data_path;
|
DataPaths::patch_exceptions = true;
|
||||||
|
}
|
||||||
if (!data_path.empty() && !original_data_path.empty()) {
|
|
||||||
// call this a c string optimization
|
// this should be called after gd is loaded but before geode
|
||||||
if (std::strncmp(pathname, original_data_path.c_str(), original_data_path.size()) == 0) {
|
extern "C" JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_performPatches(JNIEnv*, jobject) {
|
||||||
auto path = data_path + (pathname + original_data_path.size());
|
dl_iterate_phdr(on_dl_iterate, nullptr);
|
||||||
|
}
|
||||||
return path;
|
|
||||||
}
|
std::optional<std::string> redirect_path(const char* pathname) {
|
||||||
}
|
std::string_view data_path = DataPaths::data_path;
|
||||||
|
std::string_view original_data_path = DataPaths::original_data_path;
|
||||||
return pathname;
|
|
||||||
|
if (data_path.empty() || original_data_path.empty()) [[unlikely]] {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// call this a c string optimization
|
||||||
|
if (std::strncmp(pathname, original_data_path.data(), original_data_path.size()) == 0) {
|
||||||
|
auto remaining_path = (pathname + original_data_path.size());
|
||||||
|
auto remaining_len = strlen(remaining_path);
|
||||||
|
|
||||||
|
std::string x;
|
||||||
|
x.reserve(data_path.size() + remaining_len);
|
||||||
|
x.append(data_path);
|
||||||
|
x.append(remaining_path, remaining_len);
|
||||||
|
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
return std::nullopt;
|
||||||
}
|
}
|
||||||
|
|
||||||
FILE* (*fopen_original)(const char *pathname, const char *mode);
|
|
||||||
FILE* fopen_hook(const char* pathname, const char* mode) {
|
FILE* fopen_hook(const char* pathname, const char* mode) {
|
||||||
auto path_str = redirect_path(pathname);
|
auto path_str = redirect_path(pathname);
|
||||||
|
auto redirect = path_str.has_value() ? path_str->c_str() : pathname;
|
||||||
|
|
||||||
return fopen_original(path_str.c_str(), mode);
|
return fopen(redirect, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
int (*rename_original)(const char* old_path, const char* new_path);
|
|
||||||
int rename_hook(const char* old_path, const char* new_path) {
|
int rename_hook(const char* old_path, const char* new_path) {
|
||||||
auto old_path_str = redirect_path(old_path);
|
auto old_path_str = redirect_path(old_path);
|
||||||
|
auto old_redirect = old_path_str ? old_path_str->c_str() : old_path;
|
||||||
|
|
||||||
auto new_path_str = redirect_path(new_path);
|
auto new_path_str = redirect_path(new_path);
|
||||||
|
auto new_redirect = new_path_str ? new_path_str->c_str() : new_path;
|
||||||
|
|
||||||
return rename_original(old_path_str.c_str(), new_path_str.c_str());
|
return rename(old_redirect, new_redirect);
|
||||||
}
|
|
||||||
|
|
||||||
bool hook_function(void* addr, auto* hook, auto** orig) {
|
|
||||||
#ifdef USE_TULIPHOOK
|
|
||||||
using namespace tulip::hook;
|
|
||||||
|
|
||||||
HandlerMetadata metadata = {
|
|
||||||
std::make_shared<PlatformConvention>(),
|
|
||||||
AbstractFunction::from(hook)
|
|
||||||
};
|
|
||||||
|
|
||||||
auto handler_result = createHandler(addr, metadata);
|
|
||||||
if (!handler_result) {
|
|
||||||
__android_log_print(ANDROID_LOG_WARN, "GeodeLauncher-Fix",
|
|
||||||
"failed to create handler: %s\n", handler_result.unwrapErr().c_str());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
auto handler = *handler_result;
|
|
||||||
|
|
||||||
HookMetadata hook_metadata = {0};
|
|
||||||
|
|
||||||
createHook(handler, reinterpret_cast<void*>(hook), hook_metadata);
|
|
||||||
|
|
||||||
WrapperMetadata wrapper_metadata = {
|
|
||||||
std::make_unique<PlatformConvention>(),
|
|
||||||
AbstractFunction::from(hook)
|
|
||||||
};
|
|
||||||
|
|
||||||
auto wrapper = createWrapper(addr, std::move(wrapper_metadata));
|
|
||||||
if (!wrapper) {
|
|
||||||
__android_log_print(ANDROID_LOG_WARN, "GeodeLauncher-Fix",
|
|
||||||
"failed to create wrapper: %s\n", wrapper.unwrapErr().c_str());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
*reinterpret_cast<void**>(orig) = *wrapper;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
#else
|
|
||||||
DobbyHook(
|
|
||||||
addr,
|
|
||||||
reinterpret_cast<dobby_dummy_func_t>(hook),
|
|
||||||
reinterpret_cast<dobby_dummy_func_t*>(orig)
|
|
||||||
);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
[[gnu::constructor]] [[gnu::used]] void setup_hooks() {
|
|
||||||
#ifndef DISABLE_LAUNCHER_FIX
|
|
||||||
auto fopen_addr = dlsym(RTLD_NEXT, "fopen");
|
|
||||||
hook_function(fopen_addr, &fopen_hook, &fopen_original);
|
|
||||||
|
|
||||||
auto rename_addr = dlsym(RTLD_NEXT, "rename");
|
|
||||||
hook_function(rename_addr, &rename_hook, &rename_original);
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#define LOG_TAG "GeodeLauncher-Fix"
|
||||||
|
|
||||||
|
#include <android/log.h>
|
||||||
|
#include <format>
|
||||||
|
|
||||||
|
namespace log {
|
||||||
|
template<typename... Args>
|
||||||
|
void log_internal(android_LogPriority prio, std::format_string<Args...> fmt, Args&&... args) {
|
||||||
|
std::string str = std::format(fmt, std::forward<Args>(args)...);
|
||||||
|
__android_log_write(prio, LOG_TAG, str.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
template<typename... Args>
|
||||||
|
inline void debug(std::format_string<Args...> fmt, Args&&... args) {
|
||||||
|
log_internal(ANDROID_LOG_DEBUG, fmt, std::forward<Args>(args)...);
|
||||||
|
}
|
||||||
|
|
||||||
|
template<typename... Args>
|
||||||
|
inline void info(std::format_string<Args...> fmt, Args&&... args) {
|
||||||
|
log_internal(ANDROID_LOG_INFO, fmt, std::forward<Args>(args)...);
|
||||||
|
}
|
||||||
|
|
||||||
|
template<typename... Args>
|
||||||
|
inline void warn(std::format_string<Args...> fmt, Args&&... args) {
|
||||||
|
log_internal(ANDROID_LOG_WARN, fmt, std::forward<Args>(args)...);
|
||||||
|
}
|
||||||
|
|
||||||
|
template<typename... Args>
|
||||||
|
inline void error(std::format_string<Args...> fmt, Args&&... args) {
|
||||||
|
log_internal(ANDROID_LOG_ERROR, fmt, std::forward<Args>(args)...);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
#include <cstdint>
|
||||||
|
#include <span>
|
||||||
|
|
||||||
|
#include <link.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <sys/mman.h>
|
||||||
|
|
||||||
|
#include "log.hpp"
|
||||||
|
#include "base.h"
|
||||||
|
|
||||||
|
struct ElfDynamicState {
|
||||||
|
std::uint32_t* hash_table{};
|
||||||
|
char* str_table{};
|
||||||
|
ElfW(Sym)* sym_table{};
|
||||||
|
|
||||||
|
void* plt_table{};
|
||||||
|
std::uint64_t plt_table_size_bytes{};
|
||||||
|
bool plt_table_rela{};
|
||||||
|
|
||||||
|
std::uint64_t soname_idx{};
|
||||||
|
|
||||||
|
bool is_empty() const {
|
||||||
|
return !hash_table || !str_table || !sym_table || !plt_table;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// copied from tuliphook source :)
|
||||||
|
// tuliphook sets WRX perms, but we write to pages with W^X protection
|
||||||
|
bool set_mprotect(std::uintptr_t address, size_t size, int protection) {
|
||||||
|
auto const page_size = getpagesize();
|
||||||
|
auto const page_mask = page_size - 1;
|
||||||
|
|
||||||
|
auto const ptr = reinterpret_cast<uintptr_t>(address);
|
||||||
|
auto const aligned_ptr = ptr & (~page_mask);
|
||||||
|
auto const begin_size = ptr - aligned_ptr;
|
||||||
|
auto const page_count = (begin_size + size + page_mask) / page_size;
|
||||||
|
auto const aligned_size = page_count * page_size;
|
||||||
|
|
||||||
|
auto status = mprotect(reinterpret_cast<void*>(aligned_ptr), aligned_size, protection);
|
||||||
|
|
||||||
|
return status == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// i copied this function from the android linker
|
||||||
|
constexpr std::uint32_t elfhash(std::string_view name) {
|
||||||
|
std::uint32_t h = 0, g;
|
||||||
|
|
||||||
|
for (const auto c : name) {
|
||||||
|
h = (h << 4) + static_cast<unsigned char>(c);
|
||||||
|
g = h & 0xf0000000;
|
||||||
|
h ^= g;
|
||||||
|
h ^= g >> 24;
|
||||||
|
}
|
||||||
|
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
constexpr std::array<const std::uint8_t, 3> SYMBOL_PATCH_VALUE{":3"};
|
||||||
|
|
||||||
|
bool patch_symbol(std::uint32_t* hash_table, char* str_table, ElfW(Sym)* sym_table, const std::string_view orig_name) {
|
||||||
|
auto hash = elfhash(orig_name);
|
||||||
|
|
||||||
|
auto nbucket = hash_table[0];
|
||||||
|
auto nchain = hash_table[1];
|
||||||
|
auto bucket = &hash_table[2];
|
||||||
|
auto chain = &bucket[nbucket];
|
||||||
|
|
||||||
|
for (auto i = bucket[hash % nbucket]; i != 0; i = chain[i]) {
|
||||||
|
auto sym = sym_table + i;
|
||||||
|
auto sym_name = str_table + sym->st_name;
|
||||||
|
|
||||||
|
if (orig_name == sym_name) {
|
||||||
|
// there's probably no point to checking this, honestly
|
||||||
|
if (ELF_ST_BIND(sym->st_info) == STB_GLOBAL && ELF_ST_TYPE(sym->st_info) == STT_FUNC) {
|
||||||
|
// we found it! now go rename the symbol
|
||||||
|
|
||||||
|
log::debug("Disabling symbol at {} ({})", reinterpret_cast<void*>(sym_name), sym_name);
|
||||||
|
|
||||||
|
if (!set_mprotect(reinterpret_cast<std::uintptr_t>(sym_name), SYMBOL_PATCH_VALUE.size(), PROT_READ | PROT_WRITE)) {
|
||||||
|
log::warn("failed to patch symbol: could not set writable flag");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
memcpy(sym_name, SYMBOL_PATCH_VALUE.data(), SYMBOL_PATCH_VALUE.size());
|
||||||
|
|
||||||
|
if (!set_mprotect(reinterpret_cast<std::uintptr_t>(sym_name), SYMBOL_PATCH_VALUE.size(), PROT_READ)) {
|
||||||
|
log::warn("failed to patch symbol: could not remove writable flag");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log::warn("could not find symbol {} to patch (hash: {})", orig_name, hash);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool perform_symbol_patches(dl_phdr_info* info, const ElfDynamicState& state) {
|
||||||
|
// patch symbol names
|
||||||
|
auto has_error = false;
|
||||||
|
for (const auto symbol : remove_symbols) {
|
||||||
|
if (!patch_symbol(state.hash_table, state.str_table, state.sym_table, symbol)) {
|
||||||
|
has_error = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return !has_error;
|
||||||
|
}
|
||||||
|
|
||||||
|
#if defined(__LP64__)
|
||||||
|
#define ELF_R_SYM(i) ELF64_R_SYM(i)
|
||||||
|
#define ELF_R_TYPE(i) ELF64_R_TYPE(i)
|
||||||
|
#else
|
||||||
|
#define ELF_R_SYM(i) ELF32_R_SYM(i)
|
||||||
|
#define ELF_R_TYPE(i) ELF32_R_TYPE(i)
|
||||||
|
#endif
|
||||||
|
|
||||||
|
template <typename T, typename... Types>
|
||||||
|
concept is_one_of = (std::same_as<T, Types> || ...);
|
||||||
|
|
||||||
|
template <typename T> requires is_one_of<T, ElfW(Rela), ElfW(Rel)>
|
||||||
|
bool rewrite_symbol_reloc(std::uintptr_t addr, T& r, ElfW(Sym)* sym, void* func) {
|
||||||
|
auto type = ELF_R_TYPE(r.r_info);
|
||||||
|
|
||||||
|
// we're only interested in JUMP_SLOT symbols
|
||||||
|
if (type != R_AARCH64_JUMP_SLOT && type != R_ARM_JUMP_SLOT) {
|
||||||
|
log::warn("asked to patch unknown symbol type: {:#x}", type);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto write_addr = reinterpret_cast<std::uintptr_t*>(addr);
|
||||||
|
auto val = reinterpret_cast<std::uintptr_t>(func);
|
||||||
|
if constexpr (std::is_same_v<T, ElfW(Rela)>) {
|
||||||
|
// reloc = S + A
|
||||||
|
val += r.r_addend;
|
||||||
|
}
|
||||||
|
|
||||||
|
log::debug("Patching relocation at vaddr {:#x} (offset={:#x}): {:#x} => {:#x}", addr, r.r_offset, *write_addr, val);
|
||||||
|
|
||||||
|
if (!set_mprotect(addr, sizeof(std::uintptr_t), PROT_WRITE | PROT_READ)) {
|
||||||
|
log::warn("Failed to patch relocation: failed to add writable flag");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// why use memcpy when you can do this :)
|
||||||
|
*write_addr = val;
|
||||||
|
|
||||||
|
if (!set_mprotect(addr, sizeof(std::uintptr_t), PROT_READ)) {
|
||||||
|
log::warn("Failed to patch relocation: failed to remove writable flag");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
template <typename T> requires is_one_of<T, ElfW(Rela), ElfW(Rel)>
|
||||||
|
bool perform_reloc_patches(dl_phdr_info* info, const ElfDynamicState& state) {
|
||||||
|
auto table_size = state.plt_table_size_bytes / sizeof(T);
|
||||||
|
|
||||||
|
auto failed = false;
|
||||||
|
|
||||||
|
for (auto& plt : std::span{
|
||||||
|
reinterpret_cast<T*>(state.plt_table),
|
||||||
|
static_cast<std::size_t>(table_size)
|
||||||
|
}) {
|
||||||
|
auto sym = ELF_R_SYM(plt.r_info);
|
||||||
|
if (sym == 0) continue;
|
||||||
|
|
||||||
|
auto sym_data = state.sym_table + sym;
|
||||||
|
auto sym_name = state.str_table + sym_data->st_name;
|
||||||
|
|
||||||
|
auto reloc_addr = info->dlpi_addr + plt.r_offset;
|
||||||
|
|
||||||
|
if (strcmp(sym_name, "fopen") == 0) {
|
||||||
|
if (!rewrite_symbol_reloc(reloc_addr, plt, sym_data, reinterpret_cast<void*>(&fopen_hook))) {
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
} else if (strcmp(sym_name, "rename") == 0) {
|
||||||
|
if (!rewrite_symbol_reloc(reloc_addr, plt, sym_data, reinterpret_cast<void*>(&rename_hook))) {
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return !failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
int on_dl_iterate(dl_phdr_info* info, size_t size, void* data) {
|
||||||
|
// this is probably going to be gd
|
||||||
|
if (info->dlpi_name != nullptr && strstr(info->dlpi_name, "libcocos2dcpp.so") != nullptr) {
|
||||||
|
log::debug("Base address of libcocos2dcpp: {:#x}", info->dlpi_addr);
|
||||||
|
|
||||||
|
// step 1: get the dynamic table
|
||||||
|
std::uintptr_t dyn_addr = 0u;
|
||||||
|
|
||||||
|
for (auto& phdr : std::span{ info->dlpi_phdr, info->dlpi_phnum}) {
|
||||||
|
if (phdr.p_type == PT_DYNAMIC) {
|
||||||
|
dyn_addr = info->dlpi_addr + phdr.p_vaddr;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dyn_addr == 0u) {
|
||||||
|
log::warn("failed to find libcocos dynamic section");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// step 2: get the symbol table
|
||||||
|
auto dyn_entry = reinterpret_cast<ElfW(Dyn)*>(dyn_addr);
|
||||||
|
auto dyn_end_reached = false;
|
||||||
|
|
||||||
|
ElfDynamicState state{};
|
||||||
|
|
||||||
|
while (!dyn_end_reached) {
|
||||||
|
auto tag = dyn_entry->d_tag;
|
||||||
|
|
||||||
|
switch (dyn_entry->d_tag) {
|
||||||
|
case DT_SYMTAB:
|
||||||
|
state.sym_table = reinterpret_cast<ElfW(Sym)*>(info->dlpi_addr + dyn_entry->d_un.d_val);
|
||||||
|
break;
|
||||||
|
case DT_STRTAB:
|
||||||
|
state.str_table = reinterpret_cast<char*>(info->dlpi_addr + dyn_entry->d_un.d_val);
|
||||||
|
break;
|
||||||
|
case DT_HASH:
|
||||||
|
state.hash_table = reinterpret_cast<std::uint32_t*>(info->dlpi_addr + dyn_entry->d_un.d_val);
|
||||||
|
break;
|
||||||
|
case DT_SONAME:
|
||||||
|
state.soname_idx = dyn_entry->d_un.d_val;
|
||||||
|
break;
|
||||||
|
case DT_JMPREL:
|
||||||
|
state.plt_table = reinterpret_cast<void*>(info->dlpi_addr + dyn_entry->d_un.d_val);
|
||||||
|
break;
|
||||||
|
case DT_PLTREL: {
|
||||||
|
// for Android, arm64 = DT_RELA, arm32 = DT_REL
|
||||||
|
// could just gate this based on architecture but whatever
|
||||||
|
|
||||||
|
auto val = dyn_entry->d_un.d_val;
|
||||||
|
state.plt_table_rela = val == DT_RELA;
|
||||||
|
if (val != DT_RELA && val != DT_REL) {
|
||||||
|
log::warn("unrecognized PLTREL type {:#x}", val);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case DT_PLTRELSZ:
|
||||||
|
state.plt_table_size_bytes = dyn_entry->d_un.d_val;
|
||||||
|
break;
|
||||||
|
case DT_NULL:
|
||||||
|
dyn_end_reached = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
dyn_entry++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.is_empty()) {
|
||||||
|
log::error("failed to parse dynamic section (at least one table is null)");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool failed = false;
|
||||||
|
|
||||||
|
auto so_name = state.str_table + state.soname_idx;
|
||||||
|
if (strcmp(so_name, "libcocos2dcpp.so") != 0) {
|
||||||
|
log::warn("DT_SONAME gives us {}! that's not good", so_name);
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto reloc_failed = state.plt_table_rela
|
||||||
|
? !perform_reloc_patches<ElfW(Rela)>(info, state)
|
||||||
|
: !perform_reloc_patches<ElfW(Rel)>(info, state);
|
||||||
|
if (reloc_failed) {
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DataPaths::patch_exceptions) {
|
||||||
|
if (!perform_symbol_patches(info, state)) {
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failed) {
|
||||||
|
log::info(
|
||||||
|
"symbol patch diagnostics\nlibrary path: {}\naddrs: base={:#x} sym={} str={} hash={} plt={}",
|
||||||
|
info->dlpi_name,
|
||||||
|
info->dlpi_addr,
|
||||||
|
reinterpret_cast<void*>(state.sym_table),
|
||||||
|
reinterpret_cast<void*>(state.str_table),
|
||||||
|
reinterpret_cast<void*>(state.hash_table),
|
||||||
|
state.plt_table
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,35 +2,106 @@ package com.customRobTop
|
|||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.app.KeyguardManager
|
import android.content.ActivityNotFoundException
|
||||||
import android.content.*
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
import android.net.NetworkCapabilities
|
import android.net.NetworkCapabilities
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
import android.provider.OpenableColumns
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import android.util.Log
|
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import com.customRobTop.JniToCpp.resumeSound
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import com.customRobTop.contracts.ImportFileActivityContract
|
||||||
|
import com.customRobTop.contracts.ShareFileActivityContract
|
||||||
|
import com.geode.launcher.R
|
||||||
|
import com.geode.launcher.utils.DownloadUtils
|
||||||
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
|
import org.cocos2dx.lib.Cocos2dxGLSurfaceView
|
||||||
import org.cocos2dx.lib.Cocos2dxGLSurfaceView.Companion.closeIMEKeyboard
|
import org.cocos2dx.lib.Cocos2dxGLSurfaceView.Companion.closeIMEKeyboard
|
||||||
import org.cocos2dx.lib.Cocos2dxGLSurfaceView.Companion.openIMEKeyboard
|
import org.cocos2dx.lib.Cocos2dxGLSurfaceView.Companion.openIMEKeyboard
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.OutputStream
|
||||||
import java.lang.ref.WeakReference
|
import java.lang.ref.WeakReference
|
||||||
import java.util.*
|
import java.util.UUID
|
||||||
|
|
||||||
|
|
||||||
@Suppress("unused", "UNUSED_PARAMETER")
|
@Suppress("unused", "UNUSED_PARAMETER")
|
||||||
object BaseRobTopActivity {
|
object BaseRobTopActivity {
|
||||||
|
const val KEYBOARD_INPUT_MULTILINE = 1
|
||||||
|
const val KEYBOARD_INPUT_NUMERIC = 4
|
||||||
|
const val KEYBOARD_INPUT_PASSWORD = 2
|
||||||
|
const val LEVEL_EXTENSION: String = ".gdlvl"
|
||||||
|
|
||||||
private var isLoaded = false
|
private var isLoaded = false
|
||||||
var blockBackButton = false
|
var blockBackButton = false
|
||||||
private var keyboardActive = false
|
private var keyboardActive = false
|
||||||
|
var curKeyboardInputFlags = 0
|
||||||
|
|
||||||
var isPaused = false
|
var isPaused = false
|
||||||
|
|
||||||
lateinit var me: WeakReference<Activity>
|
lateinit var me: WeakReference<Activity>
|
||||||
private var shouldResumeSound = true
|
private var shouldResumeSound = true
|
||||||
|
|
||||||
fun setCurrentActivity(currentActivity: Activity) {
|
private var pendingExportPaths = emptyList<String>()
|
||||||
|
|
||||||
|
private lateinit var importFileActivityContract: ActivityResultLauncher<Unit>
|
||||||
|
private lateinit var shareFileActivityContract: ActivityResultLauncher<String>
|
||||||
|
private lateinit var shareFolderActivityContract: ActivityResultLauncher<Uri?>
|
||||||
|
|
||||||
|
fun setCurrentActivity(currentActivity: AppCompatActivity) {
|
||||||
me = WeakReference(currentActivity)
|
me = WeakReference(currentActivity)
|
||||||
|
|
||||||
|
importFileActivityContract = currentActivity.registerForActivityResult(ImportFileActivityContract()) { uris ->
|
||||||
|
val filePaths = copyPickedFiles(uris)
|
||||||
|
|
||||||
|
Cocos2dxGLSurfaceView.cocos2dxGLSurfaceView.queueEvent {
|
||||||
|
JniToCpp.filesPicked(filePaths.toTypedArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shareFileActivityContract = currentActivity.registerForActivityResult(ShareFileActivityContract()) { uri ->
|
||||||
|
val currentlyPending = pendingExportPaths
|
||||||
|
pendingExportPaths = emptyList()
|
||||||
|
|
||||||
|
val exportPath = currentlyPending.firstOrNull()
|
||||||
|
if (exportPath == null || uri == null) {
|
||||||
|
Cocos2dxGLSurfaceView.cocos2dxGLSurfaceView.queueEvent {
|
||||||
|
JniToCpp.fileShareFinished(false)
|
||||||
|
}
|
||||||
|
return@registerForActivityResult
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = saveToDocument(exportPath, uri)
|
||||||
|
Cocos2dxGLSurfaceView.cocos2dxGLSurfaceView.queueEvent {
|
||||||
|
JniToCpp.fileShareFinished(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shareFolderActivityContract = currentActivity.registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
|
||||||
|
val currentlyPending = pendingExportPaths
|
||||||
|
pendingExportPaths = emptyList()
|
||||||
|
|
||||||
|
if (uri == null) {
|
||||||
|
Cocos2dxGLSurfaceView.cocos2dxGLSurfaceView.queueEvent {
|
||||||
|
JniToCpp.fileShareFinished(false)
|
||||||
|
}
|
||||||
|
return@registerForActivityResult
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = saveToFolder(currentlyPending, uri) > 0
|
||||||
|
Cocos2dxGLSurfaceView.cocos2dxGLSurfaceView.queueEvent {
|
||||||
|
JniToCpp.fileShareFinished(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("HardwareIds")
|
@SuppressLint("HardwareIds")
|
||||||
@@ -63,33 +134,52 @@ object BaseRobTopActivity {
|
|||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun openURL(url: String) {
|
fun openURL(url: String) {
|
||||||
Log.d("MAIN", "Open URL")
|
|
||||||
me.get()?.runOnUiThread {
|
me.get()?.runOnUiThread {
|
||||||
try {
|
try {
|
||||||
me.get()?.startActivity(
|
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
|
||||||
Intent(
|
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
"android.intent.action.VIEW",
|
me.get()?.startActivity(intent)
|
||||||
Uri.parse(url)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} catch (e: ActivityNotFoundException) {
|
} catch (e: ActivityNotFoundException) {
|
||||||
Toast.makeText(
|
Toast.makeText(
|
||||||
me.get(),
|
me.get(),
|
||||||
"No activity found to open this URL.",
|
R.string.no_activity_found,
|
||||||
Toast.LENGTH_SHORT,
|
Toast.LENGTH_SHORT,
|
||||||
).show()
|
).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun copyToClipboard(str: String) {
|
||||||
|
val context = me.get() ?: return
|
||||||
|
context.runOnUiThread {
|
||||||
|
val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
clipboardManager.setPrimaryClip(
|
||||||
|
ClipData.newPlainText("text", str)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun getClipboardString(): String {
|
||||||
|
me.get()?.run {
|
||||||
|
val manager = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
val clip = manager.primaryClip
|
||||||
|
if (clip != null && clip.itemCount > 0) {
|
||||||
|
return clip.getItemAt(0).coerceToText(this).toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun sendMail(subject: String, body: String, to: String) {
|
fun sendMail(subject: String, body: String, to: String) {
|
||||||
me.get()?.runOnUiThread {
|
me.get()?.runOnUiThread {
|
||||||
val i = Intent("android.intent.action.SEND")
|
val i = Intent(Intent.ACTION_SEND)
|
||||||
i.type = "message/rfc822"
|
i.type = "message/rfc822"
|
||||||
i.putExtra("android.intent.extra.EMAIL", arrayOf(to))
|
i.putExtra(Intent.EXTRA_EMAIL, arrayOf(to))
|
||||||
i.putExtra("android.intent.extra.SUBJECT", subject)
|
i.putExtra(Intent.EXTRA_SUBJECT, subject)
|
||||||
i.putExtra("android.intent.extra.TEXT", body)
|
i.putExtra(Intent.EXTRA_TEXT, body)
|
||||||
try {
|
try {
|
||||||
me.get()?.startActivity(Intent.createChooser(i, "Send mail..."))
|
me.get()?.startActivity(Intent.createChooser(i, "Send mail..."))
|
||||||
} catch (e: ActivityNotFoundException) {
|
} catch (e: ActivityNotFoundException) {
|
||||||
@@ -260,28 +350,163 @@ object BaseRobTopActivity {
|
|||||||
)
|
)
|
||||||
fun logEvent(event: String) {}
|
fun logEvent(event: String) {}
|
||||||
|
|
||||||
class ReceiverScreen : BroadcastReceiver() {
|
@JvmStatic
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
fun setKeyboardInputFlags(flags: Int) {
|
||||||
when (intent.action) {
|
curKeyboardInputFlags = flags
|
||||||
Intent.ACTION_SCREEN_ON -> {
|
}
|
||||||
Log.d("TAG", "ACTION_SCREEN_ON")
|
|
||||||
if (!(me.get()?.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager).isKeyguardLocked) {
|
@JvmStatic
|
||||||
shouldResumeSound = true
|
fun pickImportFiles() {
|
||||||
}
|
try {
|
||||||
if (!isPaused && shouldResumeSound) {
|
importFileActivityContract.launch(Unit)
|
||||||
resumeSound()
|
} catch (_: ActivityNotFoundException) {
|
||||||
}
|
me.get()?.apply {
|
||||||
}
|
runOnUiThread {
|
||||||
Intent.ACTION_SCREEN_OFF -> {
|
Toast.makeText(
|
||||||
shouldResumeSound = false
|
this,
|
||||||
}
|
R.string.no_activity_found,
|
||||||
Intent.ACTION_USER_PRESENT -> {
|
Toast.LENGTH_SHORT,
|
||||||
shouldResumeSound = true
|
).show()
|
||||||
if (!isPaused) {
|
|
||||||
resumeSound()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun copyPickedFiles(uris: List<Uri>): List<String> {
|
||||||
|
val me = me.get() ?: return emptyList()
|
||||||
|
|
||||||
|
val importsDir = File(LaunchUtils.getSaveDirectory(me), "imports")
|
||||||
|
importsDir.mkdirs()
|
||||||
|
|
||||||
|
return uris.mapIndexedNotNull { index, uri ->
|
||||||
|
val displayName = displayName(uri)
|
||||||
|
?: "import_$index$LEVEL_EXTENSION"
|
||||||
|
if (!displayName.endsWith(LEVEL_EXTENSION)) {
|
||||||
|
return@mapIndexedNotNull null
|
||||||
|
}
|
||||||
|
|
||||||
|
var outputFile = File(importsDir, displayName)
|
||||||
|
var counter = 2
|
||||||
|
while (outputFile.exists()) {
|
||||||
|
outputFile = File(importsDir, "${counter}_$displayName")
|
||||||
|
counter++
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
me.contentResolver.openInputStream(uri)?.use { input ->
|
||||||
|
outputFile.outputStream().use { output ->
|
||||||
|
DownloadUtils.copyFile(input, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
outputFile.absolutePath
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
outputFile.delete()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun displayName(uri: Uri): String? {
|
||||||
|
me.get()?.contentResolver?.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursorQuery ->
|
||||||
|
if (cursorQuery.moveToFirst()) {
|
||||||
|
val displayName = cursorQuery.getString(0)
|
||||||
|
if (displayName.isNotEmpty()) {
|
||||||
|
return File(displayName).name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun shareFiles(files: Array<String>) {
|
||||||
|
pendingExportPaths = files.toList()
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (files.size == 1) {
|
||||||
|
shareFileActivityContract.launch(files.first())
|
||||||
|
return
|
||||||
|
} else if (files.size > 1) {
|
||||||
|
shareFolderActivityContract.launch(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
me.get()?.apply {
|
||||||
|
runOnUiThread {
|
||||||
|
Toast.makeText(
|
||||||
|
this,
|
||||||
|
R.string.no_activity_found,
|
||||||
|
Toast.LENGTH_SHORT,
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Cocos2dxGLSurfaceView.cocos2dxGLSurfaceView.queueEvent {
|
||||||
|
JniToCpp.fileShareFinished(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveToFolder(exportPaths: List<String>, treeUri: Uri): Int {
|
||||||
|
val uri = DocumentsContract.buildDocumentUriUsingTree(treeUri, DocumentsContract.getTreeDocumentId(treeUri))
|
||||||
|
|
||||||
|
val me = me.get() ?: return 0
|
||||||
|
|
||||||
|
return exportPaths.count { path ->
|
||||||
|
val filename = File(path).name
|
||||||
|
val documentUri = DocumentsContract.createDocument(me.contentResolver, uri, "application/octet-stream", filename)
|
||||||
|
?: return@count false
|
||||||
|
|
||||||
|
saveToDocument(path, documentUri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveToDocument(exportPath: String, uri: Uri): Boolean {
|
||||||
|
try {
|
||||||
|
FileInputStream(exportPath).use { inputStream ->
|
||||||
|
openOutput(uri).use { outputStream ->
|
||||||
|
if (outputStream != null) {
|
||||||
|
DownloadUtils.copyFile(inputStream, outputStream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureLevelExtension(uri)
|
||||||
|
return true
|
||||||
|
} catch (_: IOException) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openOutput(uri: Uri): OutputStream? {
|
||||||
|
val me = me.get() ?: return null
|
||||||
|
return try {
|
||||||
|
me.contentResolver.openOutputStream(uri, "wt")
|
||||||
|
} catch (_: IOException) {
|
||||||
|
me.contentResolver.openOutputStream(uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureLevelExtension(uri: Uri) {
|
||||||
|
val displayName = displayName(uri)
|
||||||
|
if (displayName == null || displayName.endsWith(LEVEL_EXTENSION)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val me = me.get() ?: return
|
||||||
|
|
||||||
|
val extensionIdx = displayName.lastIndexOf(".gdlvl.")
|
||||||
|
val substring = if (extensionIdx < 0) {
|
||||||
|
displayName + LEVEL_EXTENSION
|
||||||
|
} else {
|
||||||
|
displayName.substring(0, extensionIdx + 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
DocumentsContract.renameDocument(me.contentResolver, uri, substring)
|
||||||
|
} catch (_: IOException) {
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -55,4 +55,16 @@ object JniToCpp {
|
|||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
external fun videoAdShowed()
|
external fun videoAdShowed()
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
external fun filesPicked(files: Array<String>)
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
external fun fileShareFinished(success: Boolean)
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
external fun textInputCaretToEnd()
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
external fun textInputDone()
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.customRobTop.contracts
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.result.contract.ActivityResultContract
|
||||||
|
|
||||||
|
class ImportFileActivityContract : ActivityResultContract<Unit, List<Uri>>() {
|
||||||
|
override fun createIntent(
|
||||||
|
context: Context,
|
||||||
|
input: Unit
|
||||||
|
): Intent {
|
||||||
|
return Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||||
|
addCategory(Intent.CATEGORY_OPENABLE)
|
||||||
|
type = "*/*"
|
||||||
|
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun parseResult(
|
||||||
|
resultCode: Int,
|
||||||
|
intent: Intent?
|
||||||
|
): List<Uri> {
|
||||||
|
if (resultCode != Activity.RESULT_OK || intent == null) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
val clipData = intent.clipData
|
||||||
|
return if (clipData != null) {
|
||||||
|
List(clipData.itemCount) { i -> clipData.getItemAt(i).uri }
|
||||||
|
} else {
|
||||||
|
val intentData = intent.data
|
||||||
|
if (intentData != null)
|
||||||
|
listOf(intentData)
|
||||||
|
else
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.customRobTop.contracts
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
import androidx.activity.result.contract.ActivityResultContract
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class ShareFileActivityContract : ActivityResultContract<String, Uri?>() {
|
||||||
|
override fun createIntent(
|
||||||
|
context: Context,
|
||||||
|
input: String
|
||||||
|
): Intent {
|
||||||
|
return Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||||
|
addCategory(Intent.CATEGORY_OPENABLE)
|
||||||
|
type = "application/octet-stream"
|
||||||
|
putExtra(Intent.EXTRA_TITLE, File(input).name)
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
putExtra(DocumentsContract.EXTRA_INITIAL_URI,
|
||||||
|
DocumentsContract.buildDocumentUri("com.android.externalstorage.documents", "primary:Download")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun parseResult(
|
||||||
|
resultCode: Int,
|
||||||
|
intent: Intent?
|
||||||
|
): Uri? {
|
||||||
|
if (resultCode != Activity.RESULT_OK || intent == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return intent.data
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,31 +1,40 @@
|
|||||||
package com.geode.launcher
|
package com.geode.launcher
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.content.pm.ActivityInfo
|
import android.content.pm.ActivityInfo
|
||||||
import android.content.pm.ApplicationInfo
|
import android.content.pm.ApplicationInfo
|
||||||
import android.content.pm.PackageInfo
|
import android.content.pm.PackageInfo
|
||||||
|
import android.opengl.GLSurfaceView
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
|
import android.text.InputType
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import android.view.WindowInsets
|
||||||
|
import android.view.WindowManager
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
|
import androidx.activity.OnBackPressedCallback
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.appcompat.widget.AppCompatEditText
|
||||||
|
import androidx.compose.ui.platform.ComposeView
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.core.view.WindowInsetsControllerCompat
|
import androidx.core.view.WindowInsetsControllerCompat
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
import com.customRobTop.BaseRobTopActivity
|
import com.customRobTop.BaseRobTopActivity
|
||||||
import com.customRobTop.JniToCpp
|
import com.customRobTop.JniToCpp
|
||||||
|
import com.geode.launcher.main.LaunchNotification
|
||||||
|
import com.geode.launcher.main.determineDisplayedCards
|
||||||
import com.geode.launcher.utils.Constants
|
import com.geode.launcher.utils.Constants
|
||||||
|
import com.geode.launcher.utils.ConstrainedFrameLayout
|
||||||
import com.geode.launcher.utils.DownloadUtils
|
import com.geode.launcher.utils.DownloadUtils
|
||||||
import com.geode.launcher.utils.LaunchUtils
|
import com.geode.launcher.utils.GamePackageUtils
|
||||||
import com.geode.launcher.utils.GeodeUtils
|
import com.geode.launcher.utils.GeodeUtils
|
||||||
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
import com.geode.launcher.utils.PreferenceUtils
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
import org.cocos2dx.lib.Cocos2dxEditText
|
|
||||||
import org.cocos2dx.lib.Cocos2dxGLSurfaceView
|
import org.cocos2dx.lib.Cocos2dxGLSurfaceView
|
||||||
import org.cocos2dx.lib.Cocos2dxHelper
|
import org.cocos2dx.lib.Cocos2dxHelper
|
||||||
import org.cocos2dx.lib.Cocos2dxRenderer
|
import org.cocos2dx.lib.Cocos2dxRenderer
|
||||||
@@ -33,67 +42,116 @@ import org.fmod.FMOD
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
|
import java.io.IOException
|
||||||
|
import javax.microedition.khronos.egl.EGL10
|
||||||
|
import javax.microedition.khronos.egl.EGLConfig
|
||||||
|
import javax.microedition.khronos.egl.EGLDisplay
|
||||||
|
|
||||||
|
enum class DisplayMode {
|
||||||
|
DEFAULT, LIMITED, FULLSCREEN;
|
||||||
|
|
||||||
class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperListener {
|
companion object {
|
||||||
|
fun fromInt(i: Int) = when (i) {
|
||||||
|
1 -> LIMITED
|
||||||
|
2 -> FULLSCREEN
|
||||||
|
else -> DEFAULT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ratioForPreference(value: String) = when (value) {
|
||||||
|
"4_3" -> 4/3.0f
|
||||||
|
"1_1" -> 1.0f
|
||||||
|
"16_10" -> 16/10.0f
|
||||||
|
else -> 1.77f
|
||||||
|
}
|
||||||
|
|
||||||
|
class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperListener, GeodeUtils.CapabilityListener {
|
||||||
private var mGLSurfaceView: Cocos2dxGLSurfaceView? = null
|
private var mGLSurfaceView: Cocos2dxGLSurfaceView? = null
|
||||||
private val sTag = GeometryDashActivity::class.simpleName
|
|
||||||
private var mIsRunning = false
|
private var mIsRunning = false
|
||||||
private var mIsOnPause = false
|
private var mIsOnPause = false
|
||||||
private var mHasWindowFocus = false
|
private var mHasWindowFocus = false
|
||||||
private var mReceiver: BroadcastReceiver? = null
|
|
||||||
|
private var displayMode = DisplayMode.DEFAULT
|
||||||
|
private var mForceRefreshRate = false
|
||||||
|
private var mLimitedRefreshRate: Int? = null
|
||||||
|
private var mAspectRatio = 0.0f
|
||||||
|
private var mScreenZoom = 1.0f
|
||||||
|
private var mScreenZoomFit = false
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
setupUIState()
|
||||||
|
FMOD.init(this)
|
||||||
|
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
setupUIState()
|
|
||||||
|
|
||||||
// return back to main if Geometry Dash isn't found
|
// return back to main if Geometry Dash isn't found
|
||||||
if (!LaunchUtils.isGeometryDashInstalled(packageManager)) {
|
if (!GamePackageUtils.isGameInstalled(packageManager)) {
|
||||||
returnToMain()
|
returnToMain()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
createVersionFile()
|
||||||
tryLoadGame()
|
tryLoadGame()
|
||||||
} catch (e: UnsatisfiedLinkError) {
|
} catch (e: UnsatisfiedLinkError) {
|
||||||
e.printStackTrace()
|
Log.e("GeodeLauncher", "Library linkage failure", e)
|
||||||
|
|
||||||
// generates helpful information for use in debugging library load failures
|
// generates helpful information for use in debugging library load failures
|
||||||
val gdPackageInfo = packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0)
|
val gdPackageInfo = packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0)
|
||||||
|
val abiMismatch = GamePackageUtils.detectAbiMismatch(this, gdPackageInfo, e)
|
||||||
|
|
||||||
val abi = LaunchUtils.applicationArchitecture
|
val is64bit = LaunchUtils.is64bit
|
||||||
val isExtracted = gdPackageInfo.applicationInfo.flags and ApplicationInfo.FLAG_EXTRACT_NATIVE_LIBS == ApplicationInfo.FLAG_EXTRACT_NATIVE_LIBS
|
val errorMessage = when {
|
||||||
val isSplit = (gdPackageInfo.applicationInfo.splitSourceDirs?.size ?: 0) > 1
|
abiMismatch && is64bit -> LaunchUtils.LauncherError.LINKER_NEEDS_32BIT
|
||||||
|
abiMismatch -> LaunchUtils.LauncherError.LINKER_NEEDS_64BIT
|
||||||
|
e.message?.contains("__gxx_personality_v0") == true ->
|
||||||
|
LaunchUtils.LauncherError.LINKER_FAILED_STL
|
||||||
|
else -> LaunchUtils.LauncherError.LINKER_FAILED
|
||||||
|
}
|
||||||
|
|
||||||
val metadata = "Geometry Dash metadata:\nSplit sources: $isSplit\nExtracted libraries: $isExtracted\nLauncher architecture: $abi"
|
returnToMain(errorMessage, e.message, e.stackTraceToString())
|
||||||
Log.i("GeodeLauncher", metadata)
|
|
||||||
|
|
||||||
returnToMain(
|
|
||||||
getString(R.string.load_failed_link_error),
|
|
||||||
getString(R.string.load_failed_link_error_description)
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
Log.e("GeodeLauncher", "Uncaught error during game load", e)
|
||||||
|
|
||||||
returnToMain(
|
returnToMain(
|
||||||
getString(R.string.load_failed_generic_error),
|
LaunchUtils.LauncherError.GENERIC,
|
||||||
getString(R.string.load_failed_generic_error_description, e.message ?: "UnknownException")
|
e.message ?: "Unknown Exception",
|
||||||
|
e.stackTraceToString()
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||||
|
override fun handleOnBackPressed() {
|
||||||
|
mGLSurfaceView?.sendKeyBack()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
mGLSurfaceView?.manualBackEvents = true
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun returnToMain(returnTitle: String? = null, returnMessage: String? = null) {
|
private fun createVersionFile() {
|
||||||
val launchIntent = Intent(this, MainActivity::class.java).apply {
|
val versionPath = File(filesDir, "game_version.txt")
|
||||||
|
val gameVersion = GamePackageUtils.getGameVersionCode(packageManager)
|
||||||
|
|
||||||
|
versionPath.writeText("$gameVersion")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun returnToMain(
|
||||||
|
error: LaunchUtils.LauncherError? = null,
|
||||||
|
returnMessage: String? = null,
|
||||||
|
returnExtendedMessage: String? = null
|
||||||
|
) {
|
||||||
|
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)!!.apply {
|
||||||
flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
|
||||||
if (!returnTitle.isNullOrEmpty() && !returnMessage.isNullOrEmpty()) {
|
if (error != null && !returnMessage.isNullOrEmpty()) {
|
||||||
putExtra(Constants.LAUNCHER_KEY_RETURN_TITLE, returnTitle)
|
putExtra(LaunchUtils.LAUNCHER_KEY_RETURN_ERROR, error)
|
||||||
putExtra(Constants.LAUNCHER_KEY_RETURN_MESSAGE, returnMessage)
|
putExtra(LaunchUtils.LAUNCHER_KEY_RETURN_MESSAGE, returnMessage)
|
||||||
|
putExtra(LaunchUtils.LAUNCHER_KEY_RETURN_EXTENDED_MESSAGE, returnExtendedMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,39 +163,114 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
|
|
||||||
setupRedirection(gdPackageInfo)
|
setupRedirection(gdPackageInfo)
|
||||||
|
|
||||||
// request read and write permissions
|
|
||||||
requestPermissions(
|
|
||||||
arrayOf(
|
|
||||||
android.Manifest.permission.READ_EXTERNAL_STORAGE,
|
|
||||||
android.Manifest.permission.WRITE_EXTERNAL_STORAGE
|
|
||||||
),
|
|
||||||
0
|
|
||||||
)
|
|
||||||
|
|
||||||
Cocos2dxHelper.init(this, this)
|
Cocos2dxHelper.init(this, this)
|
||||||
|
|
||||||
GeodeUtils.setContext(this)
|
GeodeUtils.setContext(this)
|
||||||
|
GeodeUtils.setCapabilityListener(this)
|
||||||
|
|
||||||
tryLoadLibrary(gdPackageInfo, Constants.FMOD_LIB_NAME)
|
tryLoadLibrary(gdPackageInfo, Constants.FMOD_LIB_NAME)
|
||||||
tryLoadLibrary(gdPackageInfo, Constants.COCOS_LIB_NAME)
|
tryLoadLibrary(gdPackageInfo, Constants.COCOS_LIB_NAME)
|
||||||
|
|
||||||
if (getLoadTesting()) {
|
val gameVersionCode = GamePackageUtils.getGameVersionCode(packageManager)
|
||||||
loadTestingLibraries()
|
if (gameVersionCode >= 39L) {
|
||||||
|
// this fix requires geode v3, which is 2.206+
|
||||||
|
// there is a short period in which 2.206 users will still have geode v2, but whatever. ig
|
||||||
|
LauncherFix.enableExceptionsRenaming()
|
||||||
}
|
}
|
||||||
|
|
||||||
setContentView(createView())
|
LauncherFix.performPatches()
|
||||||
|
|
||||||
|
loadInternalMods()
|
||||||
|
|
||||||
|
val baseView = createView()
|
||||||
|
if (gameVersionCode >= 42L) {
|
||||||
|
mGLSurfaceView?.sendNewCocosEvents = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!GeodeUtils.handleSafeArea && Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
|
||||||
|
val frameLayoutParams = ViewGroup.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
)
|
||||||
|
val frameLayout = FrameLayout(this)
|
||||||
|
frameLayout.layoutParams = frameLayoutParams
|
||||||
|
|
||||||
|
frameLayout.setOnApplyWindowInsetsListener { v, insets ->
|
||||||
|
val displayInsets = insets.displayCutout
|
||||||
|
if (displayInsets != null) {
|
||||||
|
v.updatePadding(
|
||||||
|
left = displayInsets.safeInsetLeft,
|
||||||
|
top = displayInsets.safeInsetTop,
|
||||||
|
right = displayInsets.safeInsetRight,
|
||||||
|
bottom = displayInsets.safeInsetBottom
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
v.updatePadding(
|
||||||
|
left = 0, top = 0, right = 0, bottom = 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Cocos2dxGLSurfaceView.onKeyboardInsets(insets)
|
||||||
|
|
||||||
|
WindowInsets.CONSUMED
|
||||||
|
}
|
||||||
|
|
||||||
|
frameLayout.addView(baseView)
|
||||||
|
setContentView(frameLayout)
|
||||||
|
} else {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
baseView.setOnApplyWindowInsetsListener { view, insets ->
|
||||||
|
Cocos2dxGLSurfaceView.onKeyboardInsets(insets)
|
||||||
|
return@setOnApplyWindowInsetsListener view.onApplyWindowInsets(insets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setContentView(baseView)
|
||||||
|
}
|
||||||
|
|
||||||
setupPostLibraryLoad(gdPackageInfo)
|
setupPostLibraryLoad(gdPackageInfo)
|
||||||
|
|
||||||
if (!loadGeodeLibrary()) {
|
val geodeFailed = try {
|
||||||
Log.w("GeodeLauncher", "could not load Geode object!")
|
loadGeodeLibrary()
|
||||||
|
false
|
||||||
|
} catch (e: UnsatisfiedLinkError) {
|
||||||
|
handleGeodeException(e)
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
handleGeodeException(e)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
setupNotificationView(baseView, geodeFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupNotificationView(baseView: FrameLayout, geodeFailed: Boolean) {
|
||||||
|
val cards = determineDisplayedCards(this, geodeFailed)
|
||||||
|
if (cards.isNotEmpty()) {
|
||||||
|
val notificationView = ComposeView(this)
|
||||||
|
baseView.addView(notificationView)
|
||||||
|
|
||||||
|
notificationView.setContent {
|
||||||
|
LaunchNotification(cards)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleGeodeException(e: Throwable) {
|
||||||
|
e.printStackTrace()
|
||||||
|
|
||||||
|
// ignore load failures if the game is newer than what's supported
|
||||||
|
// so people in the future can use their save data
|
||||||
|
if (GamePackageUtils.getGameVersionCode(packageManager) <= Constants.SUPPORTED_VERSION_CODE) {
|
||||||
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupRedirection(packageInfo: PackageInfo) {
|
private fun setupRedirection(packageInfo: PackageInfo) {
|
||||||
try {
|
try {
|
||||||
LaunchUtils.addAssetsFromPackage(assets, packageInfo)
|
GamePackageUtils.addAssetsFromPackage(assets, packageInfo)
|
||||||
} catch (e: NoSuchMethodException) {
|
} catch (e: NoSuchMethodException) {
|
||||||
e.printStackTrace()
|
Log.e("GeodeLauncher", "Failed to add asset redirection", e)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -145,34 +278,94 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
val saveDir = LaunchUtils.getSaveDirectory(this)
|
val saveDir = LaunchUtils.getSaveDirectory(this)
|
||||||
saveDir.mkdir()
|
saveDir.mkdir()
|
||||||
|
|
||||||
|
// make sure it's loaded, just in case
|
||||||
|
System.loadLibrary("c++_shared")
|
||||||
|
|
||||||
LauncherFix.loadLibrary()
|
LauncherFix.loadLibrary()
|
||||||
LauncherFix.setOriginalDataPath(Constants.GJ_DATA_DIR)
|
LauncherFix.setOriginalDataPath(Constants.GJ_DATA_DIR)
|
||||||
LauncherFix.setDataPath(saveDir.path)
|
LauncherFix.setDataPath(saveDir.path)
|
||||||
} catch (e: UnsatisfiedLinkError) {
|
} catch (e: UnsatisfiedLinkError) {
|
||||||
e.printStackTrace()
|
Log.e("GeodeLauncher", "Failed to load LauncherFix", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupPostLibraryLoad(packageInfo: PackageInfo) {
|
private fun setupPostLibraryLoad(packageInfo: PackageInfo) {
|
||||||
FMOD.init(this)
|
|
||||||
|
|
||||||
// call native functions after native libraries init
|
// call native functions after native libraries init
|
||||||
JniToCpp.setupHSSAssets(
|
JniToCpp.setupHSSAssets(
|
||||||
packageInfo.applicationInfo.sourceDir,
|
packageInfo.applicationInfo!!.sourceDir,
|
||||||
Environment.getExternalStorageDirectory().absolutePath
|
Environment.getExternalStorageDirectory().absolutePath
|
||||||
)
|
)
|
||||||
Cocos2dxHelper.nativeSetApkPath(packageInfo.applicationInfo.sourceDir)
|
Cocos2dxHelper.nativeSetApkPath(packageInfo.applicationInfo!!.sourceDir)
|
||||||
|
|
||||||
BaseRobTopActivity.setCurrentActivity(this)
|
BaseRobTopActivity.setCurrentActivity(this)
|
||||||
registerReceiver()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("UnsafeDynamicallyLoadedCode")
|
@SuppressLint("UnsafeDynamicallyLoadedCode")
|
||||||
private fun tryLoadLibrary(packageInfo: PackageInfo, libraryName: String) {
|
private fun tryLoadLibrary(packageInfo: PackageInfo, libraryName: String) {
|
||||||
val nativeDir = getNativeLibraryDirectory(packageInfo.applicationInfo)
|
val nativeDir = getNativeLibraryDirectory(packageInfo.applicationInfo!!)
|
||||||
|
|
||||||
val libraryPath = if (nativeDir.endsWith('/')) "${nativeDir}lib$libraryName.so" else "$nativeDir/lib$libraryName.so"
|
val libraryPath = if (nativeDir.endsWith('/')) "${nativeDir}lib$libraryName.so" else "$nativeDir/lib$libraryName.so"
|
||||||
System.load(libraryPath)
|
|
||||||
|
try {
|
||||||
|
System.load(libraryPath)
|
||||||
|
} catch (ule: UnsatisfiedLinkError) {
|
||||||
|
// some devices (samsung a series, cough) have an overly restrictive application classloader
|
||||||
|
// this is a workaround for that. hopefully
|
||||||
|
if (ule.message?.contains("not accessible for the namespace") != true) {
|
||||||
|
throw ule
|
||||||
|
}
|
||||||
|
|
||||||
|
println("Using copy for library $libraryName")
|
||||||
|
loadLibraryCopy(libraryName, libraryPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("UnsafeDynamicallyLoadedCode")
|
||||||
|
private fun loadLibraryCopy(libraryName: String, libraryPath: String) {
|
||||||
|
if (libraryPath.contains("!/")) {
|
||||||
|
// library in apk can't be loaded directly
|
||||||
|
loadLibraryFromAssetsCopy(libraryName)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val library = File(libraryPath)
|
||||||
|
val libraryCopy = File(cacheDir, "lib$libraryName.so")
|
||||||
|
|
||||||
|
libraryCopy.outputStream().use { libraryOutput ->
|
||||||
|
library.inputStream().use { inputStream ->
|
||||||
|
DownloadUtils.copyFile(inputStream, libraryOutput)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
System.load(libraryCopy.path)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("UnsafeDynamicallyLoadedCode")
|
||||||
|
private fun loadLibraryFromAssetsCopy(libraryName: String) {
|
||||||
|
// loads a library loaded in assets
|
||||||
|
// this copies the library to a non-compressed directory
|
||||||
|
|
||||||
|
val arch = LaunchUtils.applicationArchitecture
|
||||||
|
val libraryFd = try {
|
||||||
|
assets.openNonAssetFd("lib/$arch/lib$libraryName.so")
|
||||||
|
} catch (_: Exception) {
|
||||||
|
throw UnsatisfiedLinkError("Could not find library lib$libraryName.so for abi $arch")
|
||||||
|
}
|
||||||
|
|
||||||
|
// copy the library to a path we can access
|
||||||
|
// there doesn't seem to be a way to load a library from a file descriptor
|
||||||
|
val libraryCopy = File(cacheDir, "lib$libraryName.so")
|
||||||
|
|
||||||
|
libraryCopy.outputStream().use { libraryOutput ->
|
||||||
|
libraryFd.createInputStream().use { inputStream ->
|
||||||
|
DownloadUtils.copyFile(inputStream, libraryOutput)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
System.load(libraryCopy.path)
|
||||||
|
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getNativeLibraryDirectory(applicationInfo: ApplicationInfo): String {
|
private fun getNativeLibraryDirectory(applicationInfo: ApplicationInfo): String {
|
||||||
@@ -200,62 +393,98 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
return "$sourceDir!/lib/$architecture/"
|
return "$sourceDir!/lib/$architecture/"
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("UnsafeDynamicallyLoadedCode")
|
private fun loadGeodeLibrary() {
|
||||||
private fun loadGeodeLibrary(): Boolean {
|
@Suppress("SENSELESS_COMPARISON")
|
||||||
// Load Geode if exists
|
if (BuildConfig.PREBUNDLED_GEODE != null) {
|
||||||
// bundling the object with the application allows for nicer backtraces
|
// attempt to load an externally provided Geode first
|
||||||
try {
|
if (!loadExternalGeode()) {
|
||||||
// put libgeode.so in jniLibs/armeabi-v7a to get this
|
copyGeodeResources()
|
||||||
System.loadLibrary("geode")
|
System.loadLibrary("geode")
|
||||||
return true
|
|
||||||
} catch (e: UnsatisfiedLinkError) {
|
|
||||||
// but users may prefer it stored with data
|
|
||||||
val geodeFilename = LaunchUtils.geodeFilename
|
|
||||||
val geodePath = File(filesDir.path, "launcher/$geodeFilename")
|
|
||||||
if (geodePath.exists()) {
|
|
||||||
System.load(geodePath.path)
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
// you know zmx i have 0 clue what this does so im
|
// Load Geode if exists
|
||||||
// just gonna like copy the binary from external
|
// bundling the object with the application allows for nicer backtraces
|
||||||
// also i get 20 million permission denied errors
|
try {
|
||||||
val externalGeodePath = LaunchUtils.getInstalledGeodePath(this) ?: return false
|
// put libgeode.so in jniLibs/armeabi-v7a to get this
|
||||||
|
System.loadLibrary("geode")
|
||||||
val copiedPath = File(filesDir.path, "copied")
|
return
|
||||||
if (copiedPath.exists()) {
|
} catch (e: UnsatisfiedLinkError) {
|
||||||
copiedPath.deleteRecursively()
|
if (!loadExternalGeode()) {
|
||||||
}
|
throw e
|
||||||
copiedPath.mkdir()
|
|
||||||
|
|
||||||
val copiedGeodePath = File(copiedPath.path, "Geode.so")
|
|
||||||
|
|
||||||
if (externalGeodePath.exists()) {
|
|
||||||
DownloadUtils.copyFile(
|
|
||||||
FileInputStream(externalGeodePath),
|
|
||||||
FileOutputStream(copiedGeodePath)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (copiedGeodePath.exists()) {
|
|
||||||
try {
|
|
||||||
println("Loading Geode from ${externalGeodePath.name}")
|
|
||||||
System.load(copiedGeodePath.path)
|
|
||||||
} catch (e: UnsatisfiedLinkError) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("UnsafeDynamicallyLoadedCode")
|
||||||
|
private fun loadExternalGeode(): Boolean {
|
||||||
|
// but users may prefer it stored with data
|
||||||
|
val geodeFilename = LaunchUtils.geodeFilename
|
||||||
|
val geodePath = File(filesDir.path, "launcher/$geodeFilename")
|
||||||
|
if (geodePath.exists()) {
|
||||||
|
println("Loading Geode from ${geodePath.path}")
|
||||||
|
System.load(geodePath.path)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// you know zmx i have 0 clue what this does so im
|
||||||
|
// just gonna like copy the binary from external
|
||||||
|
// also i get 20 million permission denied errors
|
||||||
|
val externalGeodePath = LaunchUtils.getInstalledGeodePath(this)
|
||||||
|
?: return false
|
||||||
|
|
||||||
|
val copiedPath = File(filesDir.path, "copied")
|
||||||
|
if (copiedPath.exists()) {
|
||||||
|
copiedPath.deleteRecursively()
|
||||||
|
}
|
||||||
|
copiedPath.mkdir()
|
||||||
|
|
||||||
|
val copiedGeodePath = File(copiedPath.path, "Geode.so")
|
||||||
|
|
||||||
|
if (externalGeodePath.exists()) {
|
||||||
|
DownloadUtils.copyFile(
|
||||||
|
FileInputStream(externalGeodePath),
|
||||||
|
FileOutputStream(copiedGeodePath)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (copiedGeodePath.exists()) {
|
||||||
|
// android 17 fix
|
||||||
|
copiedGeodePath.setWritable(false)
|
||||||
|
|
||||||
|
println("Loading Geode from ${externalGeodePath.name}")
|
||||||
|
System.load(copiedGeodePath.path)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createView(): FrameLayout {
|
private fun createView(): FrameLayout {
|
||||||
val frameLayoutParams = ViewGroup.LayoutParams(-1, -1)
|
val frameLayoutParams = ViewGroup.LayoutParams(
|
||||||
val frameLayout = FrameLayout(this)
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
)
|
||||||
|
val frameLayout = ConstrainedFrameLayout(this)
|
||||||
frameLayout.layoutParams = frameLayoutParams
|
frameLayout.layoutParams = frameLayoutParams
|
||||||
val editTextLayoutParams = ViewGroup.LayoutParams(-1, -2)
|
|
||||||
val editText = Cocos2dxEditText(this)
|
if (displayMode == DisplayMode.LIMITED) {
|
||||||
|
frameLayout.aspectRatio = mAspectRatio
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mScreenZoomFit && mScreenZoom != 1.0f) {
|
||||||
|
frameLayout.fitZoom = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mScreenZoom != 1.0f) {
|
||||||
|
frameLayout.zoom = mScreenZoom
|
||||||
|
}
|
||||||
|
|
||||||
|
val editTextLayoutParams = ViewGroup.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
)
|
||||||
|
val editText = AppCompatEditText(this)
|
||||||
editText.layoutParams = editTextLayoutParams
|
editText.layoutParams = editTextLayoutParams
|
||||||
frameLayout.addView(editText)
|
frameLayout.addView(editText)
|
||||||
|
|
||||||
@@ -264,51 +493,104 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
this.mGLSurfaceView = glSurfaceView
|
this.mGLSurfaceView = glSurfaceView
|
||||||
frameLayout.addView(this.mGLSurfaceView)
|
frameLayout.addView(this.mGLSurfaceView)
|
||||||
|
|
||||||
glSurfaceView.setEGLConfigChooser(5, 6, 5, 0, 16, 8)
|
glSurfaceView.setEGLContextClientVersion(2)
|
||||||
|
glSurfaceView.setEGLConfigChooser(EGLConfigChooser())
|
||||||
if (isAndroidEmulator()) {
|
|
||||||
glSurfaceView.setEGLConfigChooser(8, 8, 8, 8, 16, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
glSurfaceView.initView()
|
glSurfaceView.initView()
|
||||||
glSurfaceView.setCocos2dxRenderer(Cocos2dxRenderer())
|
|
||||||
|
|
||||||
editText.inputType = 145
|
val renderer = Cocos2dxRenderer(glSurfaceView)
|
||||||
|
glSurfaceView.setCocos2dxRenderer(renderer)
|
||||||
|
|
||||||
|
renderer.setFrameRate = mForceRefreshRate
|
||||||
|
|
||||||
|
val frameRate = mLimitedRefreshRate
|
||||||
|
if (frameRate != null) {
|
||||||
|
renderer.limitFrameRate(frameRate)
|
||||||
|
}
|
||||||
|
|
||||||
|
editText.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
|
||||||
glSurfaceView.cocos2dxEditText = editText
|
glSurfaceView.cocos2dxEditText = editText
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
glSurfaceView.defaultFocusHighlightEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
return frameLayout
|
return frameLayout
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupUIState() {
|
private fun setupUIState() {
|
||||||
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE
|
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE
|
||||||
|
|
||||||
|
val preferenceUtils = PreferenceUtils.get(this)
|
||||||
|
|
||||||
|
displayMode = DisplayMode.fromInt(
|
||||||
|
preferenceUtils.getInt(PreferenceUtils.Key.DISPLAY_MODE)
|
||||||
|
)
|
||||||
|
|
||||||
|
mAspectRatio = ratioForPreference(preferenceUtils.getString(PreferenceUtils.Key.CUSTOM_ASPECT_RATIO) ?: "16_9")
|
||||||
|
mScreenZoom = preferenceUtils.getInt(PreferenceUtils.Key.SCREEN_ZOOM) / 100.0f
|
||||||
|
mScreenZoomFit = preferenceUtils.getBoolean(PreferenceUtils.Key.SCREEN_ZOOM_FIT)
|
||||||
|
|
||||||
|
val limitedRefreshRate = preferenceUtils.getInt(PreferenceUtils.Key.LIMIT_FRAME_RATE).takeIf { it != 0 }
|
||||||
|
mLimitedRefreshRate = limitedRefreshRate
|
||||||
|
|
||||||
|
mForceRefreshRate = true
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
val chosenDisplay = display.supportedModes?.maxByOrNull { it.refreshRate }
|
||||||
|
if (chosenDisplay != null) {
|
||||||
|
val chosenRefreshRate = chosenDisplay.refreshRate
|
||||||
|
println("setupUIState: Forcing a refresh rate of $chosenRefreshRate (display ${chosenDisplay.modeId})")
|
||||||
|
|
||||||
|
window.attributes.preferredRefreshRate = chosenRefreshRate
|
||||||
|
} else {
|
||||||
|
println("setupUIState could not find a display to select refresh rate of...")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayMode == DisplayMode.FULLSCREEN && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
window.attributes.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||||
|
GeodeUtils.handleSafeArea = true
|
||||||
|
} else {
|
||||||
|
GeodeUtils.handleSafeArea = false
|
||||||
|
}
|
||||||
|
|
||||||
hideSystemUi()
|
hideSystemUi()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun hideSystemUi() {
|
private fun hideSystemUi() {
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
|
|
||||||
WindowCompat.getInsetsController(window, window.decorView).apply {
|
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||||
|
hide(WindowInsetsCompat.Type.systemBars())
|
||||||
systemBarsBehavior =
|
systemBarsBehavior =
|
||||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||||
hide(WindowInsetsCompat.Type.systemBars())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onWindowFocusChanged(hasWindowFocus: Boolean) {
|
||||||
|
super.onWindowFocusChanged(hasWindowFocus)
|
||||||
|
mHasWindowFocus = hasWindowFocus
|
||||||
|
if (hasWindowFocus && !mIsOnPause) {
|
||||||
|
resumeGame()
|
||||||
|
}
|
||||||
|
|
||||||
|
hideSystemUi()
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
unregisterReceivers()
|
|
||||||
FMOD.close()
|
FMOD.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resumeGame() {
|
private fun resumeGame() {
|
||||||
mIsRunning = true
|
mIsRunning = true
|
||||||
// Cocos2dxHelper.onResume()
|
Cocos2dxHelper.onResume()
|
||||||
mGLSurfaceView?.onResume()
|
mGLSurfaceView?.onResume()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun pauseGame() {
|
private fun pauseGame() {
|
||||||
mIsRunning = false
|
mIsRunning = false
|
||||||
// Cocos2dxHelper.onPause()
|
Cocos2dxHelper.onPause()
|
||||||
mGLSurfaceView?.onPause()
|
mGLSurfaceView?.onPause()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,6 +603,15 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onRestart() {
|
||||||
|
super.onRestart()
|
||||||
|
mIsOnPause = false
|
||||||
|
BaseRobTopActivity.isPaused = false
|
||||||
|
if (!this.mIsRunning) {
|
||||||
|
resumeGame()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
super.onPause()
|
super.onPause()
|
||||||
mIsOnPause = true
|
mIsOnPause = true
|
||||||
@@ -330,33 +621,6 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun registerReceiver() {
|
|
||||||
unregisterReceivers()
|
|
||||||
try {
|
|
||||||
val filter = IntentFilter(Intent.ACTION_SCREEN_ON)
|
|
||||||
filter.addAction(Intent.ACTION_SCREEN_OFF)
|
|
||||||
filter.addAction(Intent.ACTION_USER_PRESENT)
|
|
||||||
mReceiver = BaseRobTopActivity.ReceiverScreen()
|
|
||||||
registerReceiver(mReceiver, filter)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun unregisterReceivers() {
|
|
||||||
if (mReceiver != null) {
|
|
||||||
unregisterReceiver(mReceiver)
|
|
||||||
mReceiver = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onWindowFocusChanged(hasWindowFocus: Boolean) {
|
|
||||||
super.onWindowFocusChanged(hasWindowFocus)
|
|
||||||
mHasWindowFocus = hasWindowFocus
|
|
||||||
if (hasWindowFocus && !mIsOnPause) {
|
|
||||||
resumeGame()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun runOnGLThread(runnable: Runnable) {
|
override fun runOnGLThread(runnable: Runnable) {
|
||||||
mGLSurfaceView?.queueEvent(runnable)
|
mGLSurfaceView?.queueEvent(runnable)
|
||||||
}
|
}
|
||||||
@@ -383,50 +647,180 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
TODO("Not yet implemented")
|
TODO("Not yet implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isAndroidEmulator(): Boolean {
|
override fun onCapabilityAdded(capability: String): Boolean {
|
||||||
Log.d(sTag, "model=" + Build.MODEL)
|
return when (capability) {
|
||||||
val product = Build.PRODUCT
|
GeodeUtils.CAPABILITY_EXTENDED_INPUT -> {
|
||||||
Log.d(sTag, "product=$product")
|
mGLSurfaceView?.useKeyboardEvents = true
|
||||||
var isEmulator = false
|
true
|
||||||
if (product != null) {
|
}
|
||||||
isEmulator = product == "sdk" || product.contains("_sdk") || product.contains("sdk_")
|
GeodeUtils.CAPABILITY_TIMESTAMP_INPUT -> {
|
||||||
|
mGLSurfaceView?.sendTimestampEvents = true
|
||||||
|
true
|
||||||
|
}
|
||||||
|
GeodeUtils.CAPABILITY_RICH_INPUT -> {
|
||||||
|
mGLSurfaceView?.enableControllerEvents()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
GeodeUtils.CAPABILITY_RESIZE_CALLBACK -> {
|
||||||
|
mGLSurfaceView?.supportsResizeEvents = true
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
}
|
}
|
||||||
Log.d(sTag, "isEmulator=$isEmulator")
|
|
||||||
return isEmulator
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getLoadTesting(): Boolean {
|
/**
|
||||||
val preferences = PreferenceUtils.get(this)
|
* Copies a mod from the launcher's assets to the Geode mods directory.
|
||||||
return preferences.getBoolean(PreferenceUtils.Key.LOAD_TESTING)
|
* This method is not recommended for casual use, the new mod will not be automatically removed.
|
||||||
|
*/
|
||||||
|
private fun loadInternalMods() {
|
||||||
|
val internalModBase = "mods"
|
||||||
|
|
||||||
|
val modListing = try {
|
||||||
|
assets.list(internalModBase)
|
||||||
|
} catch (_: IOException) {
|
||||||
|
emptyArray<String>()
|
||||||
|
}
|
||||||
|
|
||||||
|
val modDirectory = File(
|
||||||
|
LaunchUtils.getBaseDirectory(this),
|
||||||
|
"game/geode/mods"
|
||||||
|
)
|
||||||
|
|
||||||
|
modDirectory.mkdirs()
|
||||||
|
|
||||||
|
modListing?.forEach { fileName ->
|
||||||
|
if (fileName.endsWith(".geode")) {
|
||||||
|
val modOutput = File(modDirectory, fileName)
|
||||||
|
|
||||||
|
val mod = assets.open("$internalModBase/$fileName")
|
||||||
|
DownloadUtils.copyFile(mod, modOutput.outputStream())
|
||||||
|
|
||||||
|
println("Copied internal mod $fileName")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("UnsafeDynamicallyLoadedCode")
|
private fun copyGeodeResources() {
|
||||||
private fun loadTestingLibraries() {
|
@Suppress("SENSELESS_COMPARISON")
|
||||||
// clear data dir
|
if (BuildConfig.PREBUNDLED_GEODE == null) {
|
||||||
val testDirPath = File(filesDir.path + File.separator + "testlib" + File.separator)
|
return
|
||||||
if (testDirPath.exists()) {
|
|
||||||
testDirPath.deleteRecursively()
|
|
||||||
}
|
}
|
||||||
testDirPath.mkdir()
|
|
||||||
|
|
||||||
val dir = LaunchUtils.getBaseDirectory(this)
|
val resourcesDirectory = LaunchUtils.getGeodeResourcesDirectory(this)
|
||||||
val testingPath = File(dir, "test")
|
|
||||||
|
|
||||||
testingPath.walk().forEach {
|
val pathBase = "geode.loader"
|
||||||
if (it.isFile) {
|
val versionFilename = "version"
|
||||||
// welcome to the world of Android classloader permissions
|
|
||||||
val outputFile = File(testDirPath, it.name)
|
|
||||||
DownloadUtils.copyFile(
|
|
||||||
FileInputStream(it),
|
|
||||||
FileOutputStream(outputFile)
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
val bundledVersion = try {
|
||||||
println("Loading test library ${outputFile.name}")
|
assets.open("$pathBase/$versionFilename").bufferedReader().use {
|
||||||
System.load(outputFile.path)
|
it.readText()
|
||||||
} catch (e: UnsatisfiedLinkError) {
|
}.trimEnd()
|
||||||
e.printStackTrace()
|
} catch (_: IOException) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val versionFile = File(resourcesDirectory, versionFilename)
|
||||||
|
val resourcesVersion = if (versionFile.exists()) {
|
||||||
|
versionFile.readText().trimEnd()
|
||||||
|
} else null
|
||||||
|
|
||||||
|
if (bundledVersion == resourcesVersion) {
|
||||||
|
println("copyGeodeResources: found $resourcesVersion, expected $bundledVersion. No work needs to be done!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resourcesDirectory.exists()) {
|
||||||
|
resourcesDirectory.deleteRecursively()
|
||||||
|
}
|
||||||
|
resourcesDirectory.mkdirs()
|
||||||
|
|
||||||
|
println("copyGeodeResources: found $resourcesVersion, expected $bundledVersion. Copying assets")
|
||||||
|
|
||||||
|
val fileListing = try {
|
||||||
|
assets.list(pathBase)
|
||||||
|
} catch (_: IOException) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fileListing?.forEach { fileName ->
|
||||||
|
val resourceOutput = File(resourcesDirectory, fileName)
|
||||||
|
|
||||||
|
val resource = assets.open("$pathBase/$fileName")
|
||||||
|
DownloadUtils.copyFile(resource, resourceOutput.outputStream())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class EGLConfigChooser : GLSurfaceView.EGLConfigChooser {
|
||||||
|
// this comes from EGL14, but is unavailable on EGL10
|
||||||
|
// also EGL14 is incompatible with EGL10. so whatever
|
||||||
|
private var EGL_OPENGL_ES2_BIT: Int = 0x04
|
||||||
|
|
||||||
|
private data class ConfigValues(
|
||||||
|
val redSize: Int,
|
||||||
|
val greenSize: Int,
|
||||||
|
val blueSize: Int,
|
||||||
|
val alphaSize: Int,
|
||||||
|
val depthSize: Int,
|
||||||
|
val stencilSize: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun getAttribValue(egl: EGL10, display: EGLDisplay, config: EGLConfig, attrib: Int): Int {
|
||||||
|
val value = IntArray(1)
|
||||||
|
egl.eglGetConfigAttrib(display, config, attrib, value)
|
||||||
|
|
||||||
|
return value[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun testConfig(egl: EGL10, display: EGLDisplay, attrib: ConfigValues): EGLConfig? {
|
||||||
|
val attribList = intArrayOf(
|
||||||
|
EGL10.EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT,
|
||||||
|
EGL10.EGL_RED_SIZE, attrib.redSize,
|
||||||
|
EGL10.EGL_GREEN_SIZE, attrib.greenSize,
|
||||||
|
EGL10.EGL_BLUE_SIZE, attrib.blueSize,
|
||||||
|
EGL10.EGL_ALPHA_SIZE, attrib.alphaSize,
|
||||||
|
EGL10.EGL_DEPTH_SIZE, attrib.depthSize,
|
||||||
|
EGL10.EGL_STENCIL_SIZE, attrib.stencilSize,
|
||||||
|
EGL10.EGL_NONE
|
||||||
|
)
|
||||||
|
|
||||||
|
val configs = arrayOfNulls<EGLConfig>(1)
|
||||||
|
val numConfigs = IntArray(1)
|
||||||
|
|
||||||
|
val res = egl.eglChooseConfig(display, attribList, configs, configs.size, numConfigs)
|
||||||
|
if (res && numConfigs[0] > 0 && configs[0] != null) {
|
||||||
|
return configs[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun printConfig(egl: EGL10, display: EGLDisplay, config: EGLConfig) {
|
||||||
|
val id = getAttribValue(egl, display, config, EGL10.EGL_CONFIG_ID)
|
||||||
|
val red = getAttribValue(egl, display, config, EGL10.EGL_RED_SIZE)
|
||||||
|
val green = getAttribValue(egl, display, config, EGL10.EGL_GREEN_SIZE)
|
||||||
|
val blue = getAttribValue(egl, display, config, EGL10.EGL_BLUE_SIZE)
|
||||||
|
val alpha = getAttribValue(egl, display, config, EGL10.EGL_ALPHA_SIZE)
|
||||||
|
val depth = getAttribValue(egl, display, config, EGL10.EGL_DEPTH_SIZE)
|
||||||
|
val stencil = getAttribValue(egl, display, config, EGL10.EGL_STENCIL_SIZE)
|
||||||
|
println("EGLConfig $id: (red = $red, green = $green, blue = $blue, alpha = $alpha, depth = $depth, stencil = $stencil)")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun chooseConfig(egl: EGL10, display: EGLDisplay): EGLConfig {
|
||||||
|
val configs = listOf(
|
||||||
|
ConfigValues(8, 8, 8, 8, 16, 8),
|
||||||
|
ConfigValues(8, 8, 8, 0, 16, 8),
|
||||||
|
ConfigValues(5, 6, 5, 0, 16, 8),
|
||||||
|
ConfigValues(0, 0, 0, 0, 0, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
return configs.firstNotNullOf {
|
||||||
|
val config = testConfig(egl, display, it)
|
||||||
|
if (config != null) {
|
||||||
|
printConfig(egl, display, config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
config
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.geode.launcher
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageInstaller
|
||||||
|
import android.widget.Toast
|
||||||
|
import com.geode.launcher.main.clearDownloadedApks
|
||||||
|
|
||||||
|
class InstallReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
when (val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -1)) {
|
||||||
|
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
||||||
|
val activityIntent = intent.getParcelableExtra<Intent>(Intent.EXTRA_INTENT)
|
||||||
|
|
||||||
|
if (activityIntent != null) {
|
||||||
|
context.startActivity(activityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PackageInstaller.STATUS_SUCCESS -> {
|
||||||
|
clearDownloadedApks(context)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val msg = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
|
||||||
|
println("failed to install update: $status, $msg")
|
||||||
|
|
||||||
|
val message = context.getString(R.string.launcher_self_update_failed, msg)
|
||||||
|
Toast.makeText(context, message, Toast.LENGTH_SHORT)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.geode.launcher
|
package com.geode.launcher
|
||||||
|
|
||||||
|
import androidx.annotation.Keep
|
||||||
import com.geode.launcher.utils.Constants
|
import com.geode.launcher.utils.Constants
|
||||||
|
|
||||||
|
@Keep
|
||||||
object LauncherFix {
|
object LauncherFix {
|
||||||
fun loadLibrary() {
|
fun loadLibrary() {
|
||||||
System.loadLibrary(Constants.LAUNCHER_FIX_LIB_NAME)
|
System.loadLibrary(Constants.LAUNCHER_FIX_LIB_NAME)
|
||||||
@@ -10,4 +12,8 @@ object LauncherFix {
|
|||||||
external fun setDataPath(dataPath: String)
|
external fun setDataPath(dataPath: String)
|
||||||
|
|
||||||
external fun setOriginalDataPath(dataPath: String)
|
external fun setOriginalDataPath(dataPath: String)
|
||||||
|
|
||||||
|
external fun enableExceptionsRenaming()
|
||||||
|
|
||||||
|
external fun performPatches()
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,599 +0,0 @@
|
|||||||
package com.geode.launcher
|
|
||||||
|
|
||||||
import android.app.UiModeManager
|
|
||||||
import android.content.ActivityNotFoundException
|
|
||||||
import android.content.ClipData
|
|
||||||
import android.content.ClipboardManager
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.OnBackPressedDispatcher
|
|
||||||
import androidx.activity.compose.setContent
|
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
|
||||||
import androidx.compose.animation.animateColorAsState
|
|
||||||
import androidx.compose.animation.core.Spring
|
|
||||||
import androidx.compose.animation.core.spring
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.selection.toggleable
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.semantics.Role
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.window.Dialog
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import com.geode.launcher.api.ReleaseViewModel
|
|
||||||
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
|
||||||
import com.geode.launcher.ui.theme.LocalTheme
|
|
||||||
import com.geode.launcher.ui.theme.Theme
|
|
||||||
import com.geode.launcher.ui.theme.Typography
|
|
||||||
import com.geode.launcher.utils.LaunchUtils
|
|
||||||
import com.geode.launcher.utils.PreferenceUtils
|
|
||||||
import java.net.ConnectException
|
|
||||||
import java.net.UnknownHostException
|
|
||||||
|
|
||||||
class SettingsActivity : ComponentActivity() {
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
setContent {
|
|
||||||
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
|
|
||||||
val theme = Theme.fromInt(themeOption)
|
|
||||||
|
|
||||||
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
|
|
||||||
|
|
||||||
CompositionLocalProvider(LocalTheme provides theme) {
|
|
||||||
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption) {
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
color = MaterialTheme.colorScheme.background
|
|
||||||
) {
|
|
||||||
SettingsScreen(
|
|
||||||
onBackPressedDispatcher = onBackPressedDispatcher
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
fun onExportSaveData(context: Context) {
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
"This function is not implemented yet!",
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
fun onOpenFolder(context: Context) {
|
|
||||||
val file = LaunchUtils.getBaseDirectory(context)
|
|
||||||
val clipboardManager =
|
|
||||||
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
|
||||||
clipboardManager.setPrimaryClip(
|
|
||||||
ClipData.newPlainText(
|
|
||||||
context.getString(R.string.export_folder_tag),
|
|
||||||
file.path
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
context.getString(R.string.export_folder_copied),
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onOpenFileManager(context: Context) {
|
|
||||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
||||||
type = "vnd.android.document/directory"
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
context.startActivity(intent)
|
|
||||||
} catch (e: ActivityNotFoundException) {
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
context.getText(R.string.no_activity_found), Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun UpdateIndicator(
|
|
||||||
snackbarHostState: SnackbarHostState,
|
|
||||||
updateStatus: ReleaseViewModel.ReleaseUIState
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
var enablePopup by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
when (updateStatus) {
|
|
||||||
is ReleaseViewModel.ReleaseUIState.InUpdateCheck -> {
|
|
||||||
enablePopup = true
|
|
||||||
CircularProgressIndicator()
|
|
||||||
}
|
|
||||||
is ReleaseViewModel.ReleaseUIState.InDownload -> {
|
|
||||||
// is this the ideal design? idk
|
|
||||||
enablePopup = true
|
|
||||||
val progress = updateStatus.downloaded / updateStatus.outOf.toDouble()
|
|
||||||
|
|
||||||
CircularProgressIndicator(progress.toFloat())
|
|
||||||
}
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(updateStatus) {
|
|
||||||
// only show popup if some progress was shown
|
|
||||||
if (!enablePopup) {
|
|
||||||
return@LaunchedEffect
|
|
||||||
}
|
|
||||||
|
|
||||||
when (updateStatus) {
|
|
||||||
is ReleaseViewModel.ReleaseUIState.Failure -> {
|
|
||||||
val message = when (updateStatus.exception) {
|
|
||||||
is UnknownHostException, is ConnectException ->
|
|
||||||
context.getString(R.string.release_fetch_no_internet)
|
|
||||||
else -> context.getString(R.string.preference_check_for_updates_failed)
|
|
||||||
}
|
|
||||||
|
|
||||||
snackbarHostState.showSnackbar(message)
|
|
||||||
}
|
|
||||||
is ReleaseViewModel.ReleaseUIState.Finished -> {
|
|
||||||
if (updateStatus.hasUpdated) {
|
|
||||||
snackbarHostState.showSnackbar(
|
|
||||||
context.getString(R.string.preference_check_for_updates_success)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
snackbarHostState.showSnackbar(
|
|
||||||
context.getString(R.string.preference_check_for_updates_none_found)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun themeToKey(theme: Int): String {
|
|
||||||
return when (theme) {
|
|
||||||
1 -> stringResource(R.string.preference_theme_light)
|
|
||||||
2 -> stringResource(R.string.preference_theme_dark)
|
|
||||||
else -> stringResource(R.string.preference_theme_default)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateTheme(context: Context, theme: Int) {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
||||||
val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
|
|
||||||
uiModeManager.setApplicationNightMode(
|
|
||||||
when (theme) {
|
|
||||||
1 -> UiModeManager.MODE_NIGHT_NO
|
|
||||||
2 -> UiModeManager.MODE_NIGHT_YES
|
|
||||||
else -> UiModeManager.MODE_NIGHT_CUSTOM
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
AppCompatDelegate.setDefaultNightMode(
|
|
||||||
when (theme) {
|
|
||||||
1 -> AppCompatDelegate.MODE_NIGHT_NO
|
|
||||||
2 -> AppCompatDelegate.MODE_NIGHT_YES
|
|
||||||
else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun SettingsScreen(
|
|
||||||
onBackPressedDispatcher: OnBackPressedDispatcher?,
|
|
||||||
releaseViewModel: ReleaseViewModel = viewModel(factory = ReleaseViewModel.Factory)
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
val currentRelease by PreferenceUtils.useStringPreference(PreferenceUtils.Key.CURRENT_VERSION_TAG)
|
|
||||||
val updateStatus by releaseViewModel.uiState.collectAsState()
|
|
||||||
|
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
|
||||||
var showUpdateInProgress by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
if (showUpdateInProgress) {
|
|
||||||
LaunchedEffect(snackbarHostState) {
|
|
||||||
snackbarHostState.showSnackbar(
|
|
||||||
context.getString(R.string.preference_check_for_updates_already_updating)
|
|
||||||
)
|
|
||||||
showUpdateInProgress = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// fix theme transition by giving it the exact same animation as the top bar
|
|
||||||
val containerColor by animateColorAsState(
|
|
||||||
targetValue = MaterialTheme.colorScheme.background,
|
|
||||||
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
|
|
||||||
label = "background color"
|
|
||||||
)
|
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
|
||||||
Scaffold(
|
|
||||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
|
||||||
containerColor = containerColor,
|
|
||||||
snackbarHost = {
|
|
||||||
SnackbarHost(hostState = snackbarHostState)
|
|
||||||
},
|
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.ArrowBack,
|
|
||||||
contentDescription = context.getString(R.string.settings_back_icon_alt)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
context.getString(R.string.title_activity_settings),
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
},
|
|
||||||
scrollBehavior = scrollBehavior,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
content = { innerPadding ->
|
|
||||||
Column(
|
|
||||||
Modifier
|
|
||||||
.padding(innerPadding)
|
|
||||||
.fillMaxWidth()
|
|
||||||
.verticalScroll(rememberScrollState()),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
OptionsGroup(context.getString(R.string.preference_category_testing)) {
|
|
||||||
SettingsSelectCard(
|
|
||||||
title = stringResource(R.string.preference_theme_name),
|
|
||||||
dialogTitle = stringResource(R.string.preference_theme_select),
|
|
||||||
maxVal = 2,
|
|
||||||
preferenceKey = PreferenceUtils.Key.THEME,
|
|
||||||
toLabel = { themeToKey(it) },
|
|
||||||
extraSelectBehavior = { updateTheme(context, it) }
|
|
||||||
)
|
|
||||||
SettingsCard(
|
|
||||||
title = stringResource(R.string.preference_black_background_name),
|
|
||||||
preferenceKey = PreferenceUtils.Key.BLACK_BACKGROUND
|
|
||||||
)
|
|
||||||
SettingsCard(
|
|
||||||
title = context.getString(R.string.preference_load_testing_name),
|
|
||||||
preferenceKey = PreferenceUtils.Key.LOAD_TESTING,
|
|
||||||
)
|
|
||||||
SettingsCard(
|
|
||||||
title = context.getString(R.string.preference_load_automatically_name),
|
|
||||||
description = context.getString(R.string.preference_load_automatically_description),
|
|
||||||
preferenceKey = PreferenceUtils.Key.LOAD_AUTOMATICALLY,
|
|
||||||
)
|
|
||||||
OptionsButton(
|
|
||||||
title = context.getString(R.string.preferences_copy_external_button),
|
|
||||||
description = LaunchUtils.getBaseDirectory(context).path,
|
|
||||||
onClick = { onOpenFolder(context) }
|
|
||||||
)
|
|
||||||
OptionsButton(
|
|
||||||
title = stringResource(R.string.preferences_open_file_manager),
|
|
||||||
onClick = { onOpenFileManager(context) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
OptionsGroup(context.getString(R.string.preference_category_updater)) {
|
|
||||||
SettingsCard(
|
|
||||||
title = context.getString(R.string.preference_update_automatically_name),
|
|
||||||
preferenceKey = PreferenceUtils.Key.UPDATE_AUTOMATICALLY,
|
|
||||||
)
|
|
||||||
SettingsCard(
|
|
||||||
title = context.getString(R.string.preference_release_channel_name),
|
|
||||||
preferenceKey = PreferenceUtils.Key.RELEASE_CHANNEL,
|
|
||||||
)
|
|
||||||
OptionsCard(
|
|
||||||
title = {
|
|
||||||
OptionsTitle(
|
|
||||||
title = stringResource(R.string.preference_check_for_updates_button),
|
|
||||||
description = stringResource(
|
|
||||||
R.string.preference_check_for_updates_description,
|
|
||||||
currentRelease ?: "unknown"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.clickable(
|
|
||||||
onClick = {
|
|
||||||
if (releaseViewModel.isInUpdate) {
|
|
||||||
showUpdateInProgress = true
|
|
||||||
} else {
|
|
||||||
releaseViewModel.runReleaseCheck(true)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
role = Role.Button
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
UpdateIndicator(snackbarHostState, updateStatus)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/*
|
|
||||||
OptionsGroup("Data") {
|
|
||||||
OptionsButton(
|
|
||||||
title = "Export save data",
|
|
||||||
onClick = { onExportSaveData(context) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggleSetting(context: Context, preferenceKey: PreferenceUtils.Key): Boolean {
|
|
||||||
val preferences = PreferenceUtils.get(context)
|
|
||||||
|
|
||||||
return preferences.toggleBoolean(preferenceKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getSetting(context: Context, preferenceKey: PreferenceUtils.Key): Boolean {
|
|
||||||
val preferences = PreferenceUtils.get(context)
|
|
||||||
|
|
||||||
return preferences.getBoolean(preferenceKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun OptionsGroup(title: String, content: @Composable () -> Unit) {
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
title,
|
|
||||||
style = Typography.titleMedium,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
)
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SettingsSelectCard(
|
|
||||||
title: String,
|
|
||||||
dialogTitle: String,
|
|
||||||
maxVal: Int,
|
|
||||||
preferenceKey: PreferenceUtils.Key,
|
|
||||||
toLabel: @Composable (Int) -> String,
|
|
||||||
extraSelectBehavior: ((Int) -> Unit)? = null
|
|
||||||
) {
|
|
||||||
val preferenceValue by PreferenceUtils.useIntPreference(preferenceKey)
|
|
||||||
|
|
||||||
var showDialog by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
OptionsCard(
|
|
||||||
title = { OptionsTitle(title = title) },
|
|
||||||
modifier = Modifier
|
|
||||||
.clickable(
|
|
||||||
onClick = {
|
|
||||||
showDialog = true
|
|
||||||
},
|
|
||||||
role = Role.Button
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Text(toLabel(preferenceValue))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showDialog) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
SelectDialog(
|
|
||||||
title = dialogTitle,
|
|
||||||
onDismissRequest = {
|
|
||||||
showDialog = false
|
|
||||||
},
|
|
||||||
onSelect = { selected ->
|
|
||||||
showDialog = false
|
|
||||||
PreferenceUtils.get(context)
|
|
||||||
.setInt(preferenceKey, selected)
|
|
||||||
extraSelectBehavior?.invoke(selected)
|
|
||||||
},
|
|
||||||
initialValue = preferenceValue,
|
|
||||||
toLabel = toLabel,
|
|
||||||
optionsCount = maxVal
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SelectDialog(
|
|
||||||
title: String,
|
|
||||||
onDismissRequest: () -> Unit,
|
|
||||||
onSelect: (Int) -> Unit,
|
|
||||||
initialValue: Int,
|
|
||||||
toLabel: @Composable (Int) -> String,
|
|
||||||
optionsCount: Int,
|
|
||||||
) {
|
|
||||||
var selectedValue by remember { mutableIntStateOf(initialValue) }
|
|
||||||
|
|
||||||
Dialog(onDismissRequest = onDismissRequest) {
|
|
||||||
Card(
|
|
||||||
shape = RoundedCornerShape(16.dp)
|
|
||||||
) {
|
|
||||||
// styling a dialog is actually a little hard if you're doing what i'm doing
|
|
||||||
// maybe there's a better way to make these padding values...
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
title,
|
|
||||||
style = Typography.titleLarge,
|
|
||||||
modifier = Modifier.padding(
|
|
||||||
start = 28.dp,
|
|
||||||
top = 24.dp,
|
|
||||||
bottom = 12.dp
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
// do not give the row or column padding!! it messes up the selection effect
|
|
||||||
(0..optionsCount).forEach { id ->
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable(
|
|
||||||
onClick = { selectedValue = id },
|
|
||||||
role = Role.RadioButton
|
|
||||||
)
|
|
||||||
.padding(horizontal = 12.dp)
|
|
||||||
) {
|
|
||||||
RadioButton(
|
|
||||||
selected = selectedValue == id,
|
|
||||||
onClick = { selectedValue = id }
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
toLabel(id),
|
|
||||||
style = Typography.bodyMedium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.End,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(
|
|
||||||
bottom = 16.dp,
|
|
||||||
end = 16.dp,
|
|
||||||
top = 4.dp
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
TextButton(onClick = onDismissRequest) {
|
|
||||||
Text(stringResource(R.string.message_box_cancel))
|
|
||||||
}
|
|
||||||
|
|
||||||
TextButton(onClick = { onSelect(selectedValue) }) {
|
|
||||||
Text(stringResource(R.string.message_box_accept))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun OptionsButton(title: String, description: String? = null, onClick: () -> Unit) {
|
|
||||||
OptionsCard(
|
|
||||||
title = {
|
|
||||||
OptionsTitle(
|
|
||||||
title = title,
|
|
||||||
description = description
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.clickable(onClick = onClick, role = Role.Button)
|
|
||||||
) { }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SettingsCard(title: String, description: String? = null, preferenceKey: PreferenceUtils.Key) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val settingEnabled = remember {
|
|
||||||
mutableStateOf(getSetting(context, preferenceKey))
|
|
||||||
}
|
|
||||||
|
|
||||||
OptionsCard(
|
|
||||||
title = {
|
|
||||||
OptionsTitle(
|
|
||||||
Modifier.fillMaxWidth(0.75f),
|
|
||||||
title = title,
|
|
||||||
description = description
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier.toggleable(
|
|
||||||
value = settingEnabled.value,
|
|
||||||
onValueChange = { settingEnabled.value = toggleSetting(context, preferenceKey) },
|
|
||||||
role = Role.Switch,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Switch(checked = settingEnabled.value, onCheckedChange = null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun OptionsTitle(modifier: Modifier = Modifier, title: String, description: String? = null) {
|
|
||||||
Column(
|
|
||||||
modifier,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
) {
|
|
||||||
Text(title)
|
|
||||||
if (!description.isNullOrEmpty()) {
|
|
||||||
Text(
|
|
||||||
description,
|
|
||||||
style = Typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun OptionsCard(modifier: Modifier = Modifier, title: @Composable () -> Unit, content: @Composable () -> Unit) {
|
|
||||||
Row(
|
|
||||||
modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(64.dp)
|
|
||||||
.padding(horizontal = 16.dp),
|
|
||||||
Arrangement.SpaceBetween,
|
|
||||||
Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
title()
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
|
||||||
fun OptionsCardPreview() {
|
|
||||||
GeodeLauncherTheme {
|
|
||||||
OptionsGroup(title = "Preview Group") {
|
|
||||||
SettingsCard(
|
|
||||||
title = "Load files from /test",
|
|
||||||
description = "Very long testing description goes here. It is incredibly long, it should wrap onto a new line.",
|
|
||||||
preferenceKey = PreferenceUtils.Key.LOAD_TESTING
|
|
||||||
)
|
|
||||||
SettingsCard(
|
|
||||||
title = "Testing option 2",
|
|
||||||
preferenceKey = PreferenceUtils.Key.LOAD_AUTOMATICALLY
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showSystemUi = true)
|
|
||||||
@Composable
|
|
||||||
fun DefaultPreview() {
|
|
||||||
GeodeLauncherTheme {
|
|
||||||
SettingsScreen(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,16 +3,19 @@ package com.geode.launcher
|
|||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
import android.database.MatrixCursor
|
import android.database.MatrixCursor
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import android.os.CancellationSignal
|
import android.os.CancellationSignal
|
||||||
import android.os.ParcelFileDescriptor
|
import android.os.ParcelFileDescriptor
|
||||||
import android.provider.DocumentsContract
|
import android.provider.DocumentsContract
|
||||||
import android.provider.DocumentsProvider
|
import android.provider.DocumentsProvider
|
||||||
import android.webkit.MimeTypeMap
|
import android.webkit.MimeTypeMap
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import com.geode.launcher.utils.ApplicationIcon
|
||||||
|
import com.geode.launcher.utils.IconUtils
|
||||||
import com.geode.launcher.utils.LaunchUtils
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
private const val ROOT = "root"
|
|
||||||
|
|
||||||
private val DEFAULT_ROOT_PROJECTION: Array<String> = arrayOf(
|
private val DEFAULT_ROOT_PROJECTION: Array<String> = arrayOf(
|
||||||
DocumentsContract.Root.COLUMN_ROOT_ID,
|
DocumentsContract.Root.COLUMN_ROOT_ID,
|
||||||
DocumentsContract.Root.COLUMN_FLAGS,
|
DocumentsContract.Root.COLUMN_FLAGS,
|
||||||
@@ -30,12 +33,17 @@ private val DEFAULT_DOCUMENT_PROJECTION: Array<String> = arrayOf(
|
|||||||
DocumentsContract.Document.COLUMN_SIZE
|
DocumentsContract.Document.COLUMN_SIZE
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// a lot of this code is pulled from <https://github.com/dolphin-emu/dolphin>
|
||||||
class UserDirectoryProvider : DocumentsProvider() {
|
class UserDirectoryProvider : DocumentsProvider() {
|
||||||
|
companion object {
|
||||||
|
internal const val ROOT = "root"
|
||||||
|
}
|
||||||
|
|
||||||
private lateinit var rootDir: File
|
private lateinit var rootDir: File
|
||||||
|
|
||||||
override fun onCreate(): Boolean {
|
override fun onCreate(): Boolean {
|
||||||
val context = context ?: return false
|
val context = context ?: return false
|
||||||
rootDir = LaunchUtils.getBaseDirectory(context)
|
rootDir = LaunchUtils.getBaseDirectory(context, true)
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -55,8 +63,17 @@ class UserDirectoryProvider : DocumentsProvider() {
|
|||||||
DocumentsContract.Root.FLAG_SUPPORTS_RECENTS or
|
DocumentsContract.Root.FLAG_SUPPORTS_RECENTS or
|
||||||
DocumentsContract.Root.FLAG_SUPPORTS_SEARCH
|
DocumentsContract.Root.FLAG_SUPPORTS_SEARCH
|
||||||
)
|
)
|
||||||
add(DocumentsContract.Root.COLUMN_TITLE, context.getString(R.string.app_name))
|
|
||||||
add(DocumentsContract.Root.COLUMN_ICON, R.drawable.geode_logo)
|
val selectedIconKey = PreferenceUtils.get(context).getString(PreferenceUtils.Key.SELECTED_ICON)
|
||||||
|
?: "geode"
|
||||||
|
val selectedIcon = ApplicationIcon.fromId(selectedIconKey)
|
||||||
|
val iconDetails = IconUtils.getIconDetails(selectedIcon)
|
||||||
|
|
||||||
|
val nameId = if (selectedIcon == ApplicationIcon.SAPPHIRE) R.string.application_icon_sapphire else R.string.app_name
|
||||||
|
val logoId = if (selectedIcon == ApplicationIcon.GEODE) R.drawable.geode_logo else iconDetails.iconId
|
||||||
|
|
||||||
|
add(DocumentsContract.Root.COLUMN_TITLE, context.getString(nameId))
|
||||||
|
add(DocumentsContract.Root.COLUMN_ICON, logoId)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
@@ -147,7 +164,7 @@ class UserDirectoryProvider : DocumentsProvider() {
|
|||||||
notifyFileChange(file)
|
notifyFileChange(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun renameDocument(documentId: String, displayName: String): String? {
|
override fun renameDocument(documentId: String, displayName: String): String {
|
||||||
val file = getFileForDocumentId(documentId)
|
val file = getFileForDocumentId(documentId)
|
||||||
val dest = findFileNameForNewFile(File(file.parentFile, displayName))
|
val dest = findFileNameForNewFile(File(file.parentFile, displayName))
|
||||||
file.renameTo(dest)
|
file.renameTo(dest)
|
||||||
@@ -200,4 +217,19 @@ class UserDirectoryProvider : DocumentsProvider() {
|
|||||||
val uri = getDocumentUri(parentDocumentId)
|
val uri = getDocumentUri(parentDocumentId)
|
||||||
context!!.contentResolver.notifyChange(uri, null)
|
context!!.contentResolver.notifyChange(uri, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
|
override fun findDocumentPath(
|
||||||
|
parentDocumentId: String?,
|
||||||
|
childDocumentId: String
|
||||||
|
): DocumentsContract.Path {
|
||||||
|
if (!parentDocumentId.isNullOrEmpty()) {
|
||||||
|
// not implementing this for now...
|
||||||
|
return super.findDocumentPath(parentDocumentId, childDocumentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
val nonRootPath = listOf(ROOT) + childDocumentId.removePrefix(ROOT).split("/")
|
||||||
|
|
||||||
|
return DocumentsContract.Path(ROOT, nonRootPath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.geode.launcher.activityresult
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
import androidx.activity.result.contract.ActivityResultContract
|
||||||
|
|
||||||
|
open class GeodeOpenFileActivityResult : ActivityResultContract<GeodeOpenFileActivityResult.OpenFileParams, Uri?>() {
|
||||||
|
class OpenFileParams(val extraMimes: Array<String>, val defaultPath: Uri?) {}
|
||||||
|
override fun createIntent(context: Context, input: OpenFileParams): Intent {
|
||||||
|
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
|
||||||
|
.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||||
|
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
.putExtra(Intent.EXTRA_MIME_TYPES, input.extraMimes)
|
||||||
|
.setType("*/*")
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
if (input.defaultPath != null) {
|
||||||
|
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, input.defaultPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return intent
|
||||||
|
}
|
||||||
|
|
||||||
|
final override fun getSynchronousResult(
|
||||||
|
context: Context,
|
||||||
|
input: OpenFileParams
|
||||||
|
): SynchronousResult<Uri?>? = null
|
||||||
|
|
||||||
|
final override fun parseResult(resultCode: Int, intent: Intent?): Uri? {
|
||||||
|
return intent.takeIf { resultCode == Activity.RESULT_OK }?.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.geode.launcher.activityresult
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
import androidx.activity.result.contract.ActivityResultContract
|
||||||
|
|
||||||
|
open class GeodeOpenFilesActivityResult : ActivityResultContract<GeodeOpenFilesActivityResult.OpenFileParams, List<@JvmSuppressWildcards Uri>>() {
|
||||||
|
class OpenFileParams(val extraMimes: Array<String>, val defaultPath: Uri?) {}
|
||||||
|
override fun createIntent(context: Context, input: OpenFileParams): Intent {
|
||||||
|
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
|
||||||
|
.putExtra(Intent.EXTRA_MIME_TYPES, input.extraMimes)
|
||||||
|
.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||||
|
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
||||||
|
.setType("*/*")
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
if (input.defaultPath != null) {
|
||||||
|
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, input.defaultPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return intent
|
||||||
|
}
|
||||||
|
|
||||||
|
final override fun getSynchronousResult(
|
||||||
|
context: Context,
|
||||||
|
input: OpenFileParams
|
||||||
|
): SynchronousResult<List<@JvmSuppressWildcards Uri>>? = null
|
||||||
|
|
||||||
|
final override fun parseResult(resultCode: Int, intent: Intent?): List<Uri> {
|
||||||
|
// Gracefully stolen from ActivityResultContracts.Intent.getclipDataUris
|
||||||
|
val clipData = intent.takeIf {
|
||||||
|
resultCode == Activity.RESULT_OK
|
||||||
|
}?.clipData
|
||||||
|
val resultSet = LinkedHashSet<Uri>()
|
||||||
|
intent?.data?.let { data ->
|
||||||
|
resultSet.add(data)
|
||||||
|
}
|
||||||
|
if (clipData == null && resultSet.isEmpty()) {
|
||||||
|
return emptyList()
|
||||||
|
} else if (clipData != null) {
|
||||||
|
for (i in 0 until clipData.itemCount) {
|
||||||
|
val uri = clipData.getItemAt(i).uri
|
||||||
|
if (uri != null) {
|
||||||
|
resultSet.add(uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ArrayList(resultSet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.geode.launcher.activityresult
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
import androidx.activity.result.contract.ActivityResultContract
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
open class GeodeSaveFileActivityResult : ActivityResultContract<GeodeSaveFileActivityResult.SaveFileParams, Uri?>() {
|
||||||
|
class SaveFileParams(val mimeType: String?, val initialPath: File?)
|
||||||
|
override fun createIntent(context: Context, input: SaveFileParams): Intent {
|
||||||
|
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
|
||||||
|
.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||||
|
.setType(input.mimeType ?: "*/*")
|
||||||
|
|
||||||
|
if (input.initialPath != null) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val uri = DocumentFile.fromFile(input.initialPath).uri
|
||||||
|
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
intent.putExtra(Intent.EXTRA_TITLE, input.initialPath.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return intent
|
||||||
|
}
|
||||||
|
|
||||||
|
final override fun getSynchronousResult(
|
||||||
|
context: Context,
|
||||||
|
input: SaveFileParams
|
||||||
|
): SynchronousResult<Uri?>? = null
|
||||||
|
|
||||||
|
final override fun parseResult(resultCode: Int, intent: Intent?): Uri? {
|
||||||
|
return intent.takeIf { resultCode == Activity.RESULT_OK }?.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
package com.geode.launcher.api
|
|
||||||
|
|
||||||
import com.geode.launcher.utils.LaunchUtils
|
|
||||||
import kotlinx.datetime.Instant
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class Asset(
|
|
||||||
val url: String,
|
|
||||||
val id: Int,
|
|
||||||
val name: String,
|
|
||||||
val size: Int,
|
|
||||||
val createdAt: Instant,
|
|
||||||
val updatedAt: Instant,
|
|
||||||
val browserDownloadUrl: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
class Release(
|
|
||||||
val url: String,
|
|
||||||
val id: Int,
|
|
||||||
val targetCommitish: String,
|
|
||||||
val tagName: String,
|
|
||||||
val createdAt: Instant,
|
|
||||||
val publishedAt: Instant,
|
|
||||||
val assets: List<Asset>
|
|
||||||
) {
|
|
||||||
fun getDescription(): String {
|
|
||||||
if (tagName == "nightly") {
|
|
||||||
// get the commit from the assets
|
|
||||||
// otherwise, a separate request is needed to get the hash (ew)
|
|
||||||
val asset = assets.first()
|
|
||||||
val commit = asset.name.substring(6..12)
|
|
||||||
|
|
||||||
return "nightly-$commit"
|
|
||||||
}
|
|
||||||
|
|
||||||
return tagName
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getDescriptor(): Long {
|
|
||||||
return createdAt.epochSeconds
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getAndroidDownload(): Asset? {
|
|
||||||
// try to find an asset that matches the architecture first
|
|
||||||
val platform = LaunchUtils.platformName
|
|
||||||
|
|
||||||
val releaseSuffix = "$platform.zip"
|
|
||||||
return assets.find {
|
|
||||||
it.name.endsWith(releaseSuffix)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
package com.geode.launcher.api
|
|
||||||
|
|
||||||
import com.geode.launcher.utils.DownloadUtils.executeCoroutine
|
|
||||||
import kotlinx.serialization.ExperimentalSerializationApi
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.JsonNamingStrategy
|
|
||||||
import kotlinx.serialization.json.okio.decodeFromBufferedSource
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Request
|
|
||||||
import java.io.IOException
|
|
||||||
import java.net.URL
|
|
||||||
|
|
||||||
class ReleaseRepository(private val httpClient: OkHttpClient) {
|
|
||||||
companion object {
|
|
||||||
private const val GITHUB_API_BASE = "https://api.github.com/repos/geode-sdk/geode"
|
|
||||||
private const val GITHUB_API_HEADER = "X-GitHub-Api-Version"
|
|
||||||
private const val GITHUB_API_VERSION = "2022-11-28"
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getLatestNightlyRelease(): Release? {
|
|
||||||
val nightlyPath = "$GITHUB_API_BASE/releases/tags/nightly"
|
|
||||||
val url = URL(nightlyPath)
|
|
||||||
|
|
||||||
return getReleaseByUrl(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getLatestRelease(): Release? {
|
|
||||||
val releasePath = "$GITHUB_API_BASE/releases/latest"
|
|
||||||
val url = URL(releasePath)
|
|
||||||
|
|
||||||
return getReleaseByUrl(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalSerializationApi::class)
|
|
||||||
private suspend fun getReleaseByUrl(url: URL): Release? {
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url(url)
|
|
||||||
.addHeader("Accept", "application/json")
|
|
||||||
.addHeader(GITHUB_API_HEADER, GITHUB_API_VERSION)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val call = httpClient.newCall(request)
|
|
||||||
val response = call.executeCoroutine()
|
|
||||||
|
|
||||||
return when (response.code) {
|
|
||||||
200 -> {
|
|
||||||
val format = Json {
|
|
||||||
namingStrategy = JsonNamingStrategy.SnakeCase
|
|
||||||
ignoreUnknownKeys = true
|
|
||||||
}
|
|
||||||
|
|
||||||
val release = format.decodeFromBufferedSource<Release>(
|
|
||||||
response.body!!.source()
|
|
||||||
)
|
|
||||||
|
|
||||||
release
|
|
||||||
}
|
|
||||||
404 -> {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
throw IOException("unknown response ${response.code}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package com.geode.launcher.log
|
||||||
|
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
abstract class BaseDirectoryViewModel<T>(protected val baseDirectory: File) : ViewModel() {
|
||||||
|
private val _lineState = mutableStateListOf<T>()
|
||||||
|
val lineState = _lineState
|
||||||
|
|
||||||
|
private val _isLoading = MutableStateFlow(false)
|
||||||
|
val isLoading = _isLoading.asStateFlow()
|
||||||
|
|
||||||
|
private var logJob: Job? = null
|
||||||
|
|
||||||
|
abstract fun getFileList(): List<T>
|
||||||
|
|
||||||
|
abstract fun getItemKey(item: T): Any
|
||||||
|
|
||||||
|
open fun clearAllFiles() {
|
||||||
|
logJob?.cancel()
|
||||||
|
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
if (baseDirectory.exists()) {
|
||||||
|
val files = baseDirectory.listFiles()
|
||||||
|
files?.forEach {
|
||||||
|
it.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_lineState.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadLogs() {
|
||||||
|
_isLoading.value = true
|
||||||
|
|
||||||
|
logJob?.cancel()
|
||||||
|
logJob = viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val lines = getFileList()
|
||||||
|
|
||||||
|
_lineState.clear()
|
||||||
|
_lineState.addAll(lines)
|
||||||
|
|
||||||
|
_isLoading.value = false
|
||||||
|
|
||||||
|
logJob = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeFile(filename: String) {
|
||||||
|
logJob?.cancel()
|
||||||
|
|
||||||
|
val crashPath = File(baseDirectory, filename)
|
||||||
|
if (crashPath.exists()) {
|
||||||
|
crashPath.delete()
|
||||||
|
loadLogs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadLogs()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package com.geode.launcher.log
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.lifecycle.viewmodel.initializer
|
||||||
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
data class CrashDump(
|
||||||
|
val filename: String,
|
||||||
|
val lastModified: Instant,
|
||||||
|
val fullPath: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
class CrashViewModel(crashDirectory: File) : BaseDirectoryViewModel<CrashDump>(crashDirectory) {
|
||||||
|
companion object {
|
||||||
|
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||||
|
initializer {
|
||||||
|
val application = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as Application
|
||||||
|
val crashDirectory = LaunchUtils.getCrashDirectory(application)
|
||||||
|
|
||||||
|
CrashViewModel(crashDirectory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasIndicator = false
|
||||||
|
|
||||||
|
override fun getItemKey(item: CrashDump) = item.filename
|
||||||
|
|
||||||
|
override fun getFileList(): List<CrashDump> {
|
||||||
|
val crashDirectory = baseDirectory
|
||||||
|
|
||||||
|
if (!crashDirectory.exists()) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
val children = crashDirectory.listFiles {
|
||||||
|
// ignore indicator files (including old file)
|
||||||
|
_, name -> !LaunchUtils.CRASH_INDICATOR_NAMES.contains(name)
|
||||||
|
} ?: return emptyList()
|
||||||
|
|
||||||
|
hasIndicator = LaunchUtils.CRASH_INDICATOR_NAMES.any {
|
||||||
|
File(crashDirectory, it).exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
return children
|
||||||
|
.map {
|
||||||
|
CrashDump(
|
||||||
|
filename = it.name,
|
||||||
|
lastModified = Instant.fromEpochMilliseconds(it.lastModified()),
|
||||||
|
fullPath = it.path,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.sortedByDescending { it.lastModified }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearIndicator() {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
LaunchUtils.CRASH_INDICATOR_NAMES.forEach {
|
||||||
|
val crashFile = File(baseDirectory, it)
|
||||||
|
if (crashFile.exists()) {
|
||||||
|
crashFile.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hasIndicator = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createIndicator() {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val crashFile = File(baseDirectory, LaunchUtils.CRASH_INDICATOR_NAMES.first())
|
||||||
|
crashFile.createNewFile()
|
||||||
|
|
||||||
|
hasIndicator = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clearAllFiles() {
|
||||||
|
super.clearAllFiles()
|
||||||
|
hasIndicator = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.geode.launcher.log
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewmodel.initializer
|
||||||
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
data class GeodeLog(
|
||||||
|
val filename: String,
|
||||||
|
val lastModified: Instant,
|
||||||
|
val fileSize: Long,
|
||||||
|
val fullPath: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
class GeodeLogsViewModel(crashDirectory: File) : BaseDirectoryViewModel<GeodeLog>(crashDirectory) {
|
||||||
|
companion object {
|
||||||
|
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||||
|
initializer {
|
||||||
|
val application = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as Application
|
||||||
|
val crashDirectory = LaunchUtils.getGeodeLogsDirectory(application)
|
||||||
|
|
||||||
|
GeodeLogsViewModel(crashDirectory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemKey(item: GeodeLog) = item.filename
|
||||||
|
|
||||||
|
override fun getFileList(): List<GeodeLog> {
|
||||||
|
if (!baseDirectory.exists()) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
val children = baseDirectory.listFiles()
|
||||||
|
?: return emptyList()
|
||||||
|
|
||||||
|
return children
|
||||||
|
.map {
|
||||||
|
GeodeLog(
|
||||||
|
filename = it.name,
|
||||||
|
lastModified = Instant.fromEpochMilliseconds(it.lastModified()),
|
||||||
|
fileSize = it.length(),
|
||||||
|
fullPath = it.path,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.sortedByDescending { it.lastModified }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getFileText(filename: String): List<String> {
|
||||||
|
val logFile = File(baseDirectory, filename)
|
||||||
|
if (!logFile.exists()) {
|
||||||
|
return listOf("File does not exist!")
|
||||||
|
}
|
||||||
|
|
||||||
|
return runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
logFile.readLines()
|
||||||
|
}
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
package com.geode.launcher.log
|
||||||
|
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import okio.Buffer
|
||||||
|
import okio.BufferedSource
|
||||||
|
import java.io.IOException
|
||||||
|
import kotlin.time.ExperimentalTime
|
||||||
|
import kotlin.time.toJavaInstant
|
||||||
|
|
||||||
|
fun BufferedSource.readCChar(): Char {
|
||||||
|
return this.readUtf8CodePoint().toChar()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun BufferedSource.readCString(): String {
|
||||||
|
val buffer = StringBuilder()
|
||||||
|
|
||||||
|
var lastByte = this.readCChar()
|
||||||
|
while (lastByte != '\u0000') {
|
||||||
|
buffer.append(lastByte)
|
||||||
|
lastByte = this.readCChar()
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ProcessInformation(val processId: Int, val threadId: Int, val processUid: Int)
|
||||||
|
|
||||||
|
enum class LogPriority {
|
||||||
|
UNKNOWN, DEFAULT, VERBOSE, DEBUG, INFO, WARN, ERROR, FATAL, SILENT;
|
||||||
|
|
||||||
|
class LogPriorityIterator(
|
||||||
|
start: LogPriority,
|
||||||
|
private val end: LogPriority,
|
||||||
|
private val step: Int
|
||||||
|
) : Iterator<LogPriority> {
|
||||||
|
private var hasNext: Boolean = if (step > 0) start <= end else start >= end
|
||||||
|
private var next = if (hasNext) start else end
|
||||||
|
|
||||||
|
override fun hasNext() = hasNext
|
||||||
|
|
||||||
|
override fun next(): LogPriority {
|
||||||
|
val current = next
|
||||||
|
if (current == end) {
|
||||||
|
if (!hasNext) {
|
||||||
|
throw NoSuchElementException()
|
||||||
|
}
|
||||||
|
|
||||||
|
hasNext = false
|
||||||
|
} else {
|
||||||
|
next = LogPriority.fromInt(next.toInt() + step)
|
||||||
|
}
|
||||||
|
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LogPriorityProgression(
|
||||||
|
override val start: LogPriority,
|
||||||
|
override val endInclusive: LogPriority,
|
||||||
|
private val step: Int = 1
|
||||||
|
) : Iterable<LogPriority>, ClosedRange<LogPriority> {
|
||||||
|
override fun iterator(): Iterator<LogPriority> {
|
||||||
|
return LogPriorityIterator(start, endInclusive, step)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
infix fun downTo(to: LogPriority) = LogPriorityProgression(this, to, -1)
|
||||||
|
|
||||||
|
operator fun rangeTo(to: LogPriority) = LogPriorityProgression(this, to)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromByte(byte: Byte): LogPriority = fromInt(byte.toInt())
|
||||||
|
|
||||||
|
fun fromInt(int: Int) = when (int) {
|
||||||
|
0x1 -> DEFAULT
|
||||||
|
0x2 -> VERBOSE
|
||||||
|
0x3 -> DEBUG
|
||||||
|
0x4 -> INFO
|
||||||
|
0x5 -> WARN
|
||||||
|
0x6 -> ERROR
|
||||||
|
0x7 -> FATAL
|
||||||
|
0x8 -> SILENT
|
||||||
|
else -> UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toInt() = when (this) {
|
||||||
|
DEFAULT -> 0x1
|
||||||
|
VERBOSE -> 0x2
|
||||||
|
DEBUG -> 0x3
|
||||||
|
INFO -> 0x4
|
||||||
|
WARN -> 0x5
|
||||||
|
ERROR -> 0x6
|
||||||
|
FATAL -> 0x7
|
||||||
|
SILENT -> 0x8
|
||||||
|
else -> 0x0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toChar(): Char {
|
||||||
|
return when (this) {
|
||||||
|
VERBOSE -> 'V'
|
||||||
|
DEBUG -> 'D'
|
||||||
|
INFO -> 'I'
|
||||||
|
WARN -> 'W'
|
||||||
|
ERROR -> 'E'
|
||||||
|
FATAL -> 'F'
|
||||||
|
SILENT -> 'S'
|
||||||
|
else -> '?'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a log entry from logcat.
|
||||||
|
*/
|
||||||
|
data class LogLine @OptIn(ExperimentalTime::class) constructor(
|
||||||
|
val process: ProcessInformation,
|
||||||
|
val time: Instant,
|
||||||
|
val logId: Int,
|
||||||
|
val priority: LogPriority,
|
||||||
|
val tag: String,
|
||||||
|
val message: String
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private enum class EntryVersion {
|
||||||
|
V3, V4
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun headerSizeToVersion(size: UShort) = when (size.toInt()) {
|
||||||
|
0x18 -> EntryVersion.V3
|
||||||
|
0x1c -> EntryVersion.V4
|
||||||
|
else -> throw IOException("LogLine::fromInputStream: unknown format for (headerSize = $size)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTime::class)
|
||||||
|
fun fromBufferedSource(source: BufferedSource): LogLine {
|
||||||
|
/*
|
||||||
|
// from android <liblog/include/log/log_read.h>
|
||||||
|
// there are multiple logger entry formats
|
||||||
|
// use the header_size to determine the one you have
|
||||||
|
|
||||||
|
struct logger_entry_v3 {
|
||||||
|
uint16_t len; // length of the payload
|
||||||
|
uint16_t hdr_size; // sizeof(struct logger_entry_v3)
|
||||||
|
int32_t pid; // generating process's pid
|
||||||
|
int32_t tid; // generating process's tid
|
||||||
|
int32_t sec; // seconds since Epoch
|
||||||
|
int32_t nsec; // nanoseconds
|
||||||
|
uint32_t lid; // log id of the payload
|
||||||
|
}
|
||||||
|
|
||||||
|
struct logger_entry_v4 {
|
||||||
|
uint16_t len; // length of the payload
|
||||||
|
uint16_t hdr_size; // sizeof(struct logger_entry_v4 = 28)
|
||||||
|
int32_t pid; // generating process's pid
|
||||||
|
uint32_t tid; // generating process's tid
|
||||||
|
uint32_t sec; // seconds since Epoch
|
||||||
|
uint32_t nsec; // nanoseconds
|
||||||
|
uint32_t lid; // log id of the payload, bottom 4 bits currently
|
||||||
|
uint32_t uid; // generating process's uid
|
||||||
|
};
|
||||||
|
*/
|
||||||
|
|
||||||
|
val payloadLength = source.readShortLe().toLong()
|
||||||
|
val headerSize = source.readShortLe().toUShort()
|
||||||
|
|
||||||
|
val entryVersion = headerSizeToVersion(headerSize)
|
||||||
|
|
||||||
|
val pid = source.readIntLe()
|
||||||
|
val tid = source.readIntLe()
|
||||||
|
val sec = source.readIntLe().toLong()
|
||||||
|
val nSec = source.readIntLe()
|
||||||
|
val lid = source.readIntLe()
|
||||||
|
val uid = if (entryVersion == EntryVersion.V4)
|
||||||
|
source.readIntLe() else 0
|
||||||
|
|
||||||
|
val processInformation = ProcessInformation(pid, tid, uid)
|
||||||
|
val time = Instant.fromEpochSeconds(sec, nSec)
|
||||||
|
|
||||||
|
// the payload is split into three parts
|
||||||
|
// initial priority byte -> null terminated tag -> non null terminated message
|
||||||
|
|
||||||
|
val packetBuffer = Buffer()
|
||||||
|
source.readFully(packetBuffer, payloadLength)
|
||||||
|
|
||||||
|
val priorityByte = packetBuffer.readByte()
|
||||||
|
val priority = LogPriority.fromByte(priorityByte)
|
||||||
|
|
||||||
|
val tag = packetBuffer.readCString()
|
||||||
|
val message = packetBuffer.readUtf8()
|
||||||
|
|
||||||
|
return LogLine(
|
||||||
|
process = processInformation,
|
||||||
|
priority = priority,
|
||||||
|
time = time,
|
||||||
|
logId = lid,
|
||||||
|
tag = tag,
|
||||||
|
message = message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTime::class)
|
||||||
|
fun showException(exception: Exception) = LogLine(
|
||||||
|
process = ProcessInformation(0, 0, 0),
|
||||||
|
time = Clock.System.now(),
|
||||||
|
logId = 0,
|
||||||
|
priority = LogPriority.FATAL,
|
||||||
|
tag = "GeodeLauncher",
|
||||||
|
message = "Failed to parse log entry with ${exception.stackTraceToString()}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTime::class)
|
||||||
|
val identifier = time.toJavaInstant()
|
||||||
|
|
||||||
|
val messageTrimmed by lazy {
|
||||||
|
this.message.trim { it <= ' ' }
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTime::class)
|
||||||
|
val formattedTime by lazy { this.time.toLocalDateTime(TimeZone.currentSystemDefault()) }
|
||||||
|
val asSimpleString by lazy {
|
||||||
|
"$formattedTime [${this.priority.toChar()}/${this.tag}]: ${this.messageTrimmed}"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package com.geode.launcher.log
|
||||||
|
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.yield
|
||||||
|
import okio.Buffer
|
||||||
|
import okio.buffer
|
||||||
|
import okio.source
|
||||||
|
import java.io.EOFException
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ViewModel to manage log output from logcat.
|
||||||
|
* Currently, it loads the list of logs at init with no ability to read additional logs.
|
||||||
|
*/
|
||||||
|
class LogViewModel: ViewModel() {
|
||||||
|
// the original version of this code did support streaming btw
|
||||||
|
// the states were way too annoying to manage across multiple threads
|
||||||
|
// yay!
|
||||||
|
|
||||||
|
private val _lineState = mutableStateListOf<LogLine>()
|
||||||
|
val lineState = _lineState
|
||||||
|
|
||||||
|
private val _isLoading = MutableStateFlow(false)
|
||||||
|
val isLoading = _isLoading.asStateFlow()
|
||||||
|
|
||||||
|
private var failedToLoad = false
|
||||||
|
|
||||||
|
private var logJob: Job? = null
|
||||||
|
|
||||||
|
var filterCrashes = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
// skip debug/verbose lines by default
|
||||||
|
var logLevel = LogPriority.INFO
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
|
||||||
|
_lineState.clear()
|
||||||
|
loadLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleCrashBuffer() {
|
||||||
|
filterCrashes = !filterCrashes
|
||||||
|
|
||||||
|
_lineState.clear()
|
||||||
|
loadLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun logOutput(): List<LogLine> {
|
||||||
|
val logLines = ArrayList<LogLine>()
|
||||||
|
|
||||||
|
// -B = binary format, -d = dump logs
|
||||||
|
val logCommand = if (filterCrashes) "logcat -b crash -B -d"
|
||||||
|
else "logcat -B -d"
|
||||||
|
|
||||||
|
val logProcess = try {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Runtime.getRuntime().exec(logCommand)
|
||||||
|
}
|
||||||
|
} catch (ioe: IOException) {
|
||||||
|
ioe.printStackTrace()
|
||||||
|
logLines += LogLine.showException(ioe)
|
||||||
|
|
||||||
|
failedToLoad = true
|
||||||
|
|
||||||
|
return logLines
|
||||||
|
}
|
||||||
|
|
||||||
|
// read entire log into a buffer so no logs are added to the buffer during processing
|
||||||
|
val logBuffer = Buffer()
|
||||||
|
logProcess.inputStream.source().buffer().readAll(logBuffer)
|
||||||
|
|
||||||
|
try {
|
||||||
|
coroutineScope {
|
||||||
|
// this runs until the stream is exhausted
|
||||||
|
while (true) {
|
||||||
|
val line = LogLine.fromBufferedSource(logBuffer)
|
||||||
|
|
||||||
|
if (line.priority >= logLevel) {
|
||||||
|
logLines += line
|
||||||
|
}
|
||||||
|
|
||||||
|
yield()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: EOFException) {
|
||||||
|
// ignore, end of file reached
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
logLines += LogLine.showException(e)
|
||||||
|
|
||||||
|
// technically it maybe didn't completely fail...
|
||||||
|
failedToLoad = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return logLines
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearLogs() {
|
||||||
|
logJob?.cancel()
|
||||||
|
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
// -c = clear
|
||||||
|
Runtime.getRuntime().exec("logcat -c")
|
||||||
|
.waitFor()
|
||||||
|
}
|
||||||
|
|
||||||
|
_lineState.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dumpLogcatText(): String {
|
||||||
|
val logCommand = if (filterCrashes) "logcat -b crash -d"
|
||||||
|
else "logcat -d"
|
||||||
|
|
||||||
|
val logProcess = try {
|
||||||
|
Runtime.getRuntime().exec(logCommand)
|
||||||
|
} catch (ioe: IOException) {
|
||||||
|
ioe.printStackTrace()
|
||||||
|
return ioe.stackTraceToString()
|
||||||
|
}
|
||||||
|
|
||||||
|
return logProcess.inputStream.bufferedReader().readText()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getLogData(): String = if (failedToLoad) {
|
||||||
|
dumpLogcatText()
|
||||||
|
} else {
|
||||||
|
_lineState.joinToString("\n") { it.asSimpleString }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadLogs() {
|
||||||
|
_isLoading.value = true
|
||||||
|
|
||||||
|
logJob?.cancel()
|
||||||
|
logJob = viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val lines = logOutput()
|
||||||
|
_lineState.addAll(lines)
|
||||||
|
_isLoading.value = false
|
||||||
|
|
||||||
|
logJob = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadLogs()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.geode.launcher.main
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.sizeIn
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.AbsoluteRoundedCornerShape
|
||||||
|
import androidx.compose.material3.ElevatedCard
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.geode.launcher.R
|
||||||
|
import com.geode.launcher.utils.Constants
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
|
||||||
|
fun onDownloadGame(context: Context) {
|
||||||
|
val appUrl = "https://play.google.com/store/apps/details?id=${Constants.PACKAGE_NAME}"
|
||||||
|
try {
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||||
|
data = appUrl.toUri()
|
||||||
|
setPackage("com.android.vending")
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
Toast.makeText(context, R.string.no_activity_found, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GooglePlayBadge(modifier: Modifier = Modifier) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
Image(
|
||||||
|
painter = painterResource(id = R.drawable.google_play_badge),
|
||||||
|
contentDescription = stringResource(R.string.launcher_download_game),
|
||||||
|
modifier = modifier
|
||||||
|
.width(196.dp)
|
||||||
|
.clip(AbsoluteRoundedCornerShape(4.dp))
|
||||||
|
.clickable { onDownloadGame(context) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun InlineDialog(headline: @Composable () -> Unit, body: @Composable () -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
ElevatedCard(modifier = modifier) {
|
||||||
|
Column(modifier = Modifier.padding(18.dp).sizeIn(maxWidth = 512.dp)) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
|
) {
|
||||||
|
headline()
|
||||||
|
}
|
||||||
|
|
||||||
|
body()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,460 @@
|
|||||||
|
package com.geode.launcher.main
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.SnackbarDuration
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.SnackbarResult
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalResources
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.LinkAnnotation
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.TextLinkStyles
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.text.withLink
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.geode.launcher.preferences.ApplicationLogsActivity
|
||||||
|
import com.geode.launcher.BuildConfig
|
||||||
|
import com.geode.launcher.R
|
||||||
|
import com.geode.launcher.UserDirectoryProvider
|
||||||
|
import com.geode.launcher.preferences.GeodeLogsListingActivity
|
||||||
|
import com.geode.launcher.preferences.TextViewActivity
|
||||||
|
import com.geode.launcher.ui.theme.Typography
|
||||||
|
import com.geode.launcher.utils.GamePackageUtils
|
||||||
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
data class LoadFailureInfo(
|
||||||
|
val title: LaunchUtils.LauncherError,
|
||||||
|
val description: String? = null,
|
||||||
|
val extendedMessage: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ErrorInfoBody(failureReason: LaunchUtils.LauncherError, modifier: Modifier = Modifier) {
|
||||||
|
val headline = when (failureReason) {
|
||||||
|
LaunchUtils.LauncherError.LINKER_FAILED -> stringResource(R.string.load_failed_link_error_description)
|
||||||
|
LaunchUtils.LauncherError.CRASHED -> stringResource(R.string.load_failed_crashed_description)
|
||||||
|
else -> stringResource(R.string.load_failed_generic_error_description)
|
||||||
|
}
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
val recommendations = when (failureReason) {
|
||||||
|
LaunchUtils.LauncherError.LINKER_FAILED_STL -> {
|
||||||
|
val isDeveloper = PreferenceUtils.get(context).getBoolean(PreferenceUtils.Key.DEVELOPER_MODE)
|
||||||
|
|
||||||
|
buildList {
|
||||||
|
add(stringResource(R.string.load_failed_recommendation_update))
|
||||||
|
add(stringResource(R.string.load_failed_recommendation_report))
|
||||||
|
|
||||||
|
if (isDeveloper)
|
||||||
|
add(stringResource(R.string.load_failed_recommendation_dev_stl))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchUtils.LauncherError.LINKER_FAILED -> listOfNotNull(
|
||||||
|
stringResource(R.string.load_failed_recommendation_reinstall),
|
||||||
|
stringResource(R.string.load_failed_recommendation_update),
|
||||||
|
)
|
||||||
|
LaunchUtils.LauncherError.CRASHED -> listOf(
|
||||||
|
stringResource(R.string.load_failed_recommendation_safe_mode),
|
||||||
|
stringResource(R.string.load_failed_recommendation_report)
|
||||||
|
)
|
||||||
|
else -> listOf(
|
||||||
|
stringResource(R.string.load_failed_recommendation_reinstall),
|
||||||
|
stringResource(R.string.load_failed_recommendation_update),
|
||||||
|
stringResource(R.string.load_failed_recommendation_report)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Text(headline)
|
||||||
|
|
||||||
|
recommendations.forEach { r ->
|
||||||
|
Text("\u2022\u00A0\u00A0$r")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ErrorInfoTitle(failureReason: LaunchUtils.LauncherError) {
|
||||||
|
val message = when (failureReason) {
|
||||||
|
LaunchUtils.LauncherError.LINKER_NEEDS_64BIT,
|
||||||
|
LaunchUtils.LauncherError.LINKER_NEEDS_32BIT,
|
||||||
|
LaunchUtils.LauncherError.LINKER_FAILED_STL,
|
||||||
|
LaunchUtils.LauncherError.LINKER_FAILED -> stringResource(R.string.load_failed_link_error)
|
||||||
|
LaunchUtils.LauncherError.GENERIC -> stringResource(R.string.load_failed_generic_error)
|
||||||
|
LaunchUtils.LauncherError.CRASHED -> stringResource(R.string.load_failed_crashed)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_error),
|
||||||
|
contentDescription = stringResource(R.string.launcher_error_icon_alt),
|
||||||
|
modifier = Modifier.size(28.dp)
|
||||||
|
)
|
||||||
|
Text(message, style = Typography.headlineMedium)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
fun findSomethingForCrash(context: Context): File? {
|
||||||
|
val lastCrash = LaunchUtils.getLastCrash(context)
|
||||||
|
if (lastCrash != null) {
|
||||||
|
return lastCrash
|
||||||
|
}
|
||||||
|
|
||||||
|
val logsDirectory = LaunchUtils.getGeodeLogsDirectory(context)
|
||||||
|
if (!logsDirectory.exists()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return logsDirectory.listFiles()?.maxByOrNull { it.lastModified() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onShareCrash(context: Context) {
|
||||||
|
val lastCrash = findSomethingForCrash(context)
|
||||||
|
if (lastCrash == null) {
|
||||||
|
Toast.makeText(context, R.string.launcher_error_export_missing, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val baseDirectory = LaunchUtils.getBaseDirectory(context, true)
|
||||||
|
|
||||||
|
val providerLocation = lastCrash.toRelativeString(baseDirectory)
|
||||||
|
val documentsPath = "${UserDirectoryProvider.ROOT}${providerLocation}"
|
||||||
|
|
||||||
|
val uri = DocumentsContract.buildDocumentUri("${context.packageName}.user", documentsPath)
|
||||||
|
|
||||||
|
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||||
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
|
type = "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
context.startActivity(shareIntent)
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
Toast.makeText(context, R.string.no_activity_found, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openLastCrash(context: Context) {
|
||||||
|
val lastCrash = findSomethingForCrash(context)
|
||||||
|
if (lastCrash == null) {
|
||||||
|
Toast.makeText(context, R.string.launcher_error_export_missing, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val launchIntent = Intent(context, TextViewActivity::class.java).run {
|
||||||
|
putExtra(TextViewActivity.EXTRA_LOG_VIEW_FILENAME, lastCrash.path)
|
||||||
|
}
|
||||||
|
context.startActivity(launchIntent)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onShowLogs(context: Context) {
|
||||||
|
val launchIntent = Intent(context, ApplicationLogsActivity::class.java)
|
||||||
|
context.startActivity(launchIntent)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ErrorInfoDescription(description: String, modifier: Modifier = Modifier) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val currentRelease = remember {
|
||||||
|
@Suppress("SENSELESS_COMPARISON")
|
||||||
|
if (BuildConfig.PREBUNDLED_GEODE != null)
|
||||||
|
"${BuildConfig.PREBUNDLED_GEODE}*"
|
||||||
|
else
|
||||||
|
PreferenceUtils.get(context).getString(PreferenceUtils.Key.CURRENT_VERSION_TAG)
|
||||||
|
}
|
||||||
|
|
||||||
|
val gameVersion = remember {
|
||||||
|
GamePackageUtils.getUnifiedVersionName(context.packageManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectionContainer {
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.launcher_error_details),
|
||||||
|
style = Typography.headlineSmall
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
description,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
style = Typography.bodyMedium
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.size(4.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.launcher_error_device_info,
|
||||||
|
Build.MODEL,
|
||||||
|
Build.VERSION.RELEASE,
|
||||||
|
currentRelease ?: "unknown",
|
||||||
|
gameVersion,
|
||||||
|
LaunchUtils.applicationArchitecture,
|
||||||
|
BuildConfig.VERSION_NAME
|
||||||
|
),
|
||||||
|
style = Typography.labelMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
fun ErrorInfoActions(extraDetails: String?, modifier: Modifier = Modifier) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
val clipboard = LocalClipboard.current
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val resources = LocalResources.current
|
||||||
|
|
||||||
|
FlowRow(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(
|
||||||
|
12.dp, alignment = Alignment.CenterHorizontally
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if (extraDetails != null) {
|
||||||
|
Button(onClick = { onShowLogs(context) }) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_description),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||||
|
Text(stringResource(R.string.launcher_error_view_logs))
|
||||||
|
}
|
||||||
|
|
||||||
|
FilledTonalButton(onClick = {
|
||||||
|
val clipboardLabel = resources.getString(R.string.application_log_copy_label)
|
||||||
|
|
||||||
|
coroutineScope.launch {
|
||||||
|
clipboard.setClipEntry(
|
||||||
|
ClipEntry(ClipData.newPlainText(
|
||||||
|
clipboardLabel, extraDetails
|
||||||
|
))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_content_copy),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||||
|
Text(stringResource(R.string.launcher_error_copy))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val crashFile = remember { findSomethingForCrash(context) }
|
||||||
|
val readableCrash = remember { crashFile?.extension == "log" }
|
||||||
|
|
||||||
|
if (crashFile != null) {
|
||||||
|
Button(onClick = { onShareCrash(context) }) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_share),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||||
|
Text(stringResource(R.string.launcher_error_export_crash))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (readableCrash) {
|
||||||
|
FilledTonalButton(onClick = { openLastCrash(context) }) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_description),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||||
|
Text(stringResource(R.string.launcher_error_view_details))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Button(onClick = {
|
||||||
|
val launchIntent = Intent(context, GeodeLogsListingActivity::class.java)
|
||||||
|
context.startActivity(launchIntent)
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_description),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||||
|
Text(stringResource(R.string.launcher_error_view_logs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DownloadRecommendation(needsUniversal: Boolean, modifier: Modifier = Modifier) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val version = BuildConfig.VERSION_NAME
|
||||||
|
|
||||||
|
val showDownload = remember { GamePackageUtils.showDownloadBadge(context.packageManager) }
|
||||||
|
|
||||||
|
val downloadBase = "https://github.com/geode-sdk/android-launcher/releases/download/$version"
|
||||||
|
val legacyDownloadUrl = "$downloadBase/geode-launcher-v$version-android32.apk"
|
||||||
|
val universalDownloadUrl = "$downloadBase/geode-launcher-v$version.apk"
|
||||||
|
|
||||||
|
val downloadUrl = if (needsUniversal)
|
||||||
|
universalDownloadUrl else legacyDownloadUrl
|
||||||
|
|
||||||
|
val downloadText = if (needsUniversal)
|
||||||
|
stringResource(R.string.launcher_download_universal)
|
||||||
|
else
|
||||||
|
stringResource(R.string.launcher_download_32bit)
|
||||||
|
|
||||||
|
val promptTitle = stringResource(R.string.load_failed_abi_error)
|
||||||
|
val reinstallText = stringResource(R.string.load_failed_recommendation_reinstall)
|
||||||
|
val recommendationText = if (needsUniversal)
|
||||||
|
stringResource(R.string.load_failed_recommendation_switch_64bit_abi)
|
||||||
|
else
|
||||||
|
stringResource(R.string.load_failed_recommendation_switch_32bit_abi)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(
|
||||||
|
12.dp, alignment = Alignment.CenterVertically
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(promptTitle)
|
||||||
|
Text("\u2022\u00A0\u00A0$reinstallText")
|
||||||
|
|
||||||
|
if (showDownload) {
|
||||||
|
GooglePlayBadge()
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("\u2022\u00A0\u00A0$recommendationText")
|
||||||
|
|
||||||
|
Text(
|
||||||
|
buildAnnotatedString {
|
||||||
|
withLink(LinkAnnotation.Url(
|
||||||
|
downloadUrl,
|
||||||
|
TextLinkStyles(
|
||||||
|
style = SpanStyle(
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
textDecoration = TextDecoration.Underline
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)) {
|
||||||
|
append(downloadText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ErrorInfoSheet(failureInfo: LoadFailureInfo, onDismiss: () -> Unit) {
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
|
||||||
|
ModalBottomSheet(onDismissRequest = { onDismiss() }, sheetState = sheetState) {
|
||||||
|
// second container created for scroll (before padding is added)
|
||||||
|
Box(Modifier.verticalScroll(rememberScrollState())) {
|
||||||
|
Column(modifier = Modifier
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
) {
|
||||||
|
ErrorInfoTitle(failureInfo.title)
|
||||||
|
|
||||||
|
Spacer(Modifier.size(16.dp))
|
||||||
|
|
||||||
|
if (failureInfo.title.isAbiFailure()) {
|
||||||
|
DownloadRecommendation(
|
||||||
|
needsUniversal = failureInfo.title == LaunchUtils.LauncherError.LINKER_NEEDS_64BIT,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 8.dp, bottom = 16.dp)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ErrorInfoBody(failureInfo.title)
|
||||||
|
|
||||||
|
if (failureInfo.description != null) {
|
||||||
|
ErrorInfoDescription(
|
||||||
|
failureInfo.description,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrorInfoActions(
|
||||||
|
extraDetails = failureInfo.extendedMessage ?: failureInfo.description,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 8.dp, bottom = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun showFailureSnackbar(
|
||||||
|
context: Context,
|
||||||
|
loadFailureInfo: LoadFailureInfo,
|
||||||
|
snackbarHostState: SnackbarHostState,
|
||||||
|
onActionPerformed: () -> Unit
|
||||||
|
) {
|
||||||
|
val message = if (loadFailureInfo.title == LaunchUtils.LauncherError.CRASHED) {
|
||||||
|
context.getString(R.string.launcher_crashed)
|
||||||
|
} else {
|
||||||
|
context.getString(R.string.launcher_failed_to_load)
|
||||||
|
}
|
||||||
|
|
||||||
|
val res = snackbarHostState.showSnackbar(
|
||||||
|
message = message,
|
||||||
|
actionLabel = context.getString(R.string.launcher_error_more),
|
||||||
|
duration = SnackbarDuration.Indefinite,
|
||||||
|
)
|
||||||
|
|
||||||
|
when (res) {
|
||||||
|
SnackbarResult.ActionPerformed -> {
|
||||||
|
onActionPerformed()
|
||||||
|
}
|
||||||
|
SnackbarResult.Dismissed -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
package com.geode.launcher.main
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.PressInteraction
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.geode.launcher.GeometryDashActivity
|
||||||
|
import com.geode.launcher.R
|
||||||
|
import com.geode.launcher.preferences.SettingsActivity
|
||||||
|
import com.geode.launcher.utils.Constants
|
||||||
|
import com.geode.launcher.utils.GamePackageUtils
|
||||||
|
import com.geode.launcher.utils.GeodeUtils
|
||||||
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UpdateWarning(inSafeMode: Boolean = false, onDismiss: () -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val packageManager = context.packageManager
|
||||||
|
|
||||||
|
val gdVersionCode = remember { GamePackageUtils.getGameVersionCode(packageManager) }
|
||||||
|
val gdVersionString = remember { GamePackageUtils.getGameVersionString(packageManager) }
|
||||||
|
|
||||||
|
var lastDismissedVersion by PreferenceUtils.useLongPreference(
|
||||||
|
preferenceKey = PreferenceUtils.Key.DISMISSED_GJ_UPDATE
|
||||||
|
)
|
||||||
|
|
||||||
|
val onUnsupportedVersion = gdVersionCode < Constants.SUPPORTED_VERSION_CODE_MIN || gdVersionCode > Constants.SUPPORTED_VERSION_CODE
|
||||||
|
val canDismissRelease = gdVersionCode >= Constants.SUPPORTED_VERSION_CODE
|
||||||
|
val shouldDismiss = canDismissRelease && gdVersionCode == lastDismissedVersion
|
||||||
|
|
||||||
|
if (onUnsupportedVersion && !shouldDismiss) {
|
||||||
|
AlertDialog(
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_warning),
|
||||||
|
contentDescription = stringResource(R.string.launcher_warning_icon_alt)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(stringResource(R.string.launcher_unsupported_version_title))
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
R.string.launcher_unsupported_version_description,
|
||||||
|
gdVersionString,
|
||||||
|
Constants.SUPPORTED_VERSION_STRING
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
onDismiss()
|
||||||
|
lastDismissedVersion = gdVersionCode
|
||||||
|
|
||||||
|
onLaunch(context, inSafeMode)
|
||||||
|
}) {
|
||||||
|
Text(stringResource(R.string.message_box_accept))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { onDismiss() }) {
|
||||||
|
Text(stringResource(R.string.message_box_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismissRequest = { onDismiss() }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LaunchedEffect(gdVersionCode) {
|
||||||
|
onLaunch(context, inSafeMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SafeModeDialog(onDismiss: () -> Unit, onLaunch: () -> Unit) {
|
||||||
|
AlertDialog(
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_shield_alert),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
title = { Text(stringResource(R.string.preference_launch_safe_mode_confirm_title)) },
|
||||||
|
text = { Text(stringResource(R.string.safe_mode_enable_description)) },
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.message_box_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onLaunch) {
|
||||||
|
Text(stringResource(R.string.message_box_continue))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismissRequest = onDismiss
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onLaunch(context: Context, safeMode: Boolean = false) {
|
||||||
|
if (safeMode) {
|
||||||
|
GeodeUtils.setAdditionalLaunchArguments(GeodeUtils.ARGUMENT_SAFE_MODE)
|
||||||
|
} else {
|
||||||
|
GeodeUtils.clearLaunchArguments()
|
||||||
|
}
|
||||||
|
|
||||||
|
val launchIntent = Intent(context, GeometryDashActivity::class.java)
|
||||||
|
launchIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
|
||||||
|
context.startActivity(launchIntent)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSettings(context: Context) {
|
||||||
|
val launchIntent = Intent(context, SettingsActivity::class.java)
|
||||||
|
context.startActivity(launchIntent)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LongPressButton(onClick: () -> Unit, onLongPress: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, content: @Composable RowScope.() -> Unit) {
|
||||||
|
// compose apis don't provide a good way of adding long press to a button
|
||||||
|
var isLongPress by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
val viewConfiguration = LocalViewConfiguration.current
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
|
|
||||||
|
LaunchedEffect(interactionSource) {
|
||||||
|
interactionSource.interactions.collectLatest { interaction ->
|
||||||
|
when (interaction) {
|
||||||
|
is PressInteraction.Press -> {
|
||||||
|
delay(viewConfiguration.longPressTimeoutMillis.milliseconds)
|
||||||
|
|
||||||
|
// perform a second delay to make the action more obvious
|
||||||
|
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
delay(viewConfiguration.longPressTimeoutMillis.milliseconds)
|
||||||
|
|
||||||
|
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
|
||||||
|
isLongPress = true
|
||||||
|
onLongPress()
|
||||||
|
}
|
||||||
|
is PressInteraction.Release -> {
|
||||||
|
if (!isLongPress) {
|
||||||
|
onClick()
|
||||||
|
}
|
||||||
|
isLongPress = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {},
|
||||||
|
enabled = enabled,
|
||||||
|
modifier = modifier,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UnsupportedVersionWarning(modifier: Modifier = Modifier) {
|
||||||
|
val gameVersion = GamePackageUtils.getSupportedUnifiedVersion()
|
||||||
|
|
||||||
|
InlineDialog(
|
||||||
|
headline = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_warning),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(28.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.launcher_unsupported_version_title),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
body = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.launcher_game_update_required, gameVersion),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = modifier.padding(8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
package com.geode.launcher.main
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.MutableTransitionState
|
||||||
|
import androidx.compose.animation.expandHorizontally
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.shrinkHorizontally
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
|
import androidx.compose.foundation.layout.displayCutoutPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedCard
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.geode.launcher.R
|
||||||
|
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||||
|
import com.geode.launcher.ui.theme.LocalTheme
|
||||||
|
import com.geode.launcher.ui.theme.Theme
|
||||||
|
import com.geode.launcher.updater.ReleaseManager
|
||||||
|
import com.geode.launcher.utils.Constants
|
||||||
|
import com.geode.launcher.utils.GamePackageUtils
|
||||||
|
import com.geode.launcher.utils.GeodeUtils
|
||||||
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
enum class LaunchNotificationType {
|
||||||
|
GEODE_UPDATED, LAUNCHER_UPDATE_AVAILABLE, UPDATE_FAILED, UNSUPPORTED_VERSION, FAILED_LOAD;
|
||||||
|
}
|
||||||
|
|
||||||
|
fun determineDisplayedCards(context: Context, geodeFailed: Boolean = false): List<LaunchNotificationType> {
|
||||||
|
val cards = mutableListOf<LaunchNotificationType>()
|
||||||
|
|
||||||
|
if (GamePackageUtils.getGameVersionCode(context.packageManager) < Constants.SUPPORTED_VERSION_CODE_MIN_WARNING) {
|
||||||
|
cards.add(LaunchNotificationType.UNSUPPORTED_VERSION)
|
||||||
|
}
|
||||||
|
|
||||||
|
val releaseManager = ReleaseManager.get(context)
|
||||||
|
val availableUpdate = releaseManager.availableLauncherUpdateTag.value
|
||||||
|
if (availableUpdate != null && !releaseManager.dismissedLauncherUpdate) {
|
||||||
|
cards.add(LaunchNotificationType.LAUNCHER_UPDATE_AVAILABLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
val releaseState = releaseManager.uiState.value
|
||||||
|
|
||||||
|
if (releaseState is ReleaseManager.ReleaseManagerState.Finished && releaseState.hasUpdated) {
|
||||||
|
cards.add(LaunchNotificationType.GEODE_UPDATED)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (releaseState is ReleaseManager.ReleaseManagerState.Failure) {
|
||||||
|
cards.add(LaunchNotificationType.UPDATE_FAILED)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (geodeFailed) {
|
||||||
|
cards.add(LaunchNotificationType.FAILED_LOAD)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cards
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NotificationCardFromType(type: LaunchNotificationType) {
|
||||||
|
when (type) {
|
||||||
|
LaunchNotificationType.LAUNCHER_UPDATE_AVAILABLE -> {
|
||||||
|
var showInfoDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
if (showInfoDialog) {
|
||||||
|
LauncherUpdateInformation {
|
||||||
|
showInfoDialog = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedNotificationCard(
|
||||||
|
displayLength = 5.seconds,
|
||||||
|
onClick = {
|
||||||
|
showInfoDialog = true
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
LauncherUpdateContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchNotificationType.UPDATE_FAILED -> {
|
||||||
|
AnimatedNotificationCard {
|
||||||
|
UpdateFailedContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchNotificationType.UNSUPPORTED_VERSION -> {
|
||||||
|
AnimatedNotificationCard {
|
||||||
|
OutdatedVersionContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchNotificationType.FAILED_LOAD -> {
|
||||||
|
AnimatedNotificationCard {
|
||||||
|
ForwardCompatibilityContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LaunchNotification(cards: List<LaunchNotificationType>) {
|
||||||
|
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
|
||||||
|
val theme = Theme.fromInt(themeOption)
|
||||||
|
|
||||||
|
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalTheme provides theme) {
|
||||||
|
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption) {
|
||||||
|
// surface is not in use, so this is unfortunately not provided
|
||||||
|
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
|
||||||
|
Column(
|
||||||
|
modifier = if (GeodeUtils.handleSafeArea)
|
||||||
|
Modifier.displayCutoutPadding()
|
||||||
|
else Modifier
|
||||||
|
) {
|
||||||
|
cards.forEach {
|
||||||
|
NotificationCardFromType(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AnimatedNotificationCard(modifier: Modifier = Modifier, visibilityDelay: Duration = Duration.ZERO, displayLength: Duration = 3.seconds, onClick: (() -> Unit)? = null, contents: @Composable () -> Unit) {
|
||||||
|
val state = remember {
|
||||||
|
MutableTransitionState(false).apply {
|
||||||
|
targetState = visibilityDelay <= Duration.ZERO
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(true) {
|
||||||
|
if (visibilityDelay > Duration.ZERO) {
|
||||||
|
delay(visibilityDelay)
|
||||||
|
state.targetState = true
|
||||||
|
}
|
||||||
|
|
||||||
|
delay(displayLength)
|
||||||
|
state.targetState = false
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visibleState = state,
|
||||||
|
enter = expandHorizontally() + fadeIn(),
|
||||||
|
exit = shrinkHorizontally() + fadeOut(),
|
||||||
|
modifier = modifier
|
||||||
|
) {
|
||||||
|
CardView(onClick, modifier = Modifier.padding(8.dp)) {
|
||||||
|
contents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CardView(onClick: (() -> Unit)?, modifier: Modifier = Modifier, contents: @Composable () -> Unit) {
|
||||||
|
// val surfaceColor = MaterialTheme.colorScheme.background.copy(alpha = 0.75f)
|
||||||
|
|
||||||
|
if (onClick != null) {
|
||||||
|
OutlinedCard(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier
|
||||||
|
) {
|
||||||
|
contents()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
OutlinedCard(
|
||||||
|
modifier = modifier
|
||||||
|
) {
|
||||||
|
contents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LauncherUpdateContent(modifier: Modifier = Modifier) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(text = stringResource(id = R.string.launcher_update_available))
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(text = stringResource(id = R.string.launcher_notification_update_cta))
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_info),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = modifier.width(IntrinsicSize.Max)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
@Composable
|
||||||
|
fun UpdateNotificationContent(modifier: Modifier = Modifier) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(text = stringResource(id = R.string.launcher_notification_update_success))
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.geode_monochrome),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = modifier.width(IntrinsicSize.Max)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun OutdatedVersionContent(modifier: Modifier = Modifier) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(text = stringResource(id = R.string.launcher_notification_compatibility))
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_warning),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(text = stringResource(id = R.string.launcher_notification_compatibility_description))
|
||||||
|
},
|
||||||
|
modifier = modifier.width(IntrinsicSize.Max)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UpdateFailedContent(modifier: Modifier = Modifier) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(text = stringResource(id = R.string.launcher_notification_update_failed))
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_warning),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = modifier.width(IntrinsicSize.Max)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ForwardCompatibilityContent(modifier: Modifier = Modifier) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(text = stringResource(id = R.string.launcher_notification_forwards_compatibility))
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_warning),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(text = stringResource(id = R.string.launcher_notification_forwards_compatibility_description))
|
||||||
|
},
|
||||||
|
modifier = modifier.width(IntrinsicSize.Max)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
package com.geode.launcher.main
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.os.CountDownTimer
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.lifecycle.viewmodel.initializer
|
||||||
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
|
import com.geode.launcher.BuildConfig
|
||||||
|
import com.geode.launcher.updater.ReleaseManager
|
||||||
|
import com.geode.launcher.utils.Constants
|
||||||
|
import com.geode.launcher.utils.GamePackageUtils
|
||||||
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.takeWhile
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class LaunchArguments(
|
||||||
|
val autoSafeMode: Boolean,
|
||||||
|
val forcePause: Boolean,
|
||||||
|
val forceLaunch: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
class LaunchViewModel(private val application: Application): ViewModel() {
|
||||||
|
companion object {
|
||||||
|
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||||
|
initializer {
|
||||||
|
val application = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as Application
|
||||||
|
|
||||||
|
LaunchViewModel(
|
||||||
|
application = application
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var readyTimerPassed = false
|
||||||
|
private var readyTimer: CountDownTimer? = null
|
||||||
|
|
||||||
|
private fun initReadyTimer() {
|
||||||
|
val cooldownLength = PreferenceUtils.get(application).getInt(PreferenceUtils.Key.WAIT_PERIOD) * 1000L
|
||||||
|
if (cooldownLength == 0L) {
|
||||||
|
readyTimerPassed = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
readyTimer = object : CountDownTimer(cooldownLength, 1000) {
|
||||||
|
override fun onTick(millisUntilFinished: Long) {
|
||||||
|
// no tick necessary
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFinish() {
|
||||||
|
readyTimerPassed = true
|
||||||
|
|
||||||
|
if (_uiState.value is LaunchUIState.Working) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
preReadyCheck()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class LaunchCancelReason {
|
||||||
|
MANUAL, AUTOMATIC, LAST_LAUNCH_CRASHED, LAST_LAUNCH_CRASHED_AUTOMATIC,
|
||||||
|
GEODE_NOT_FOUND, GAME_MISSING, GAME_OUTDATED;
|
||||||
|
|
||||||
|
fun allowsRetry() = when (this) {
|
||||||
|
MANUAL,
|
||||||
|
AUTOMATIC,
|
||||||
|
LAST_LAUNCH_CRASHED,
|
||||||
|
LAST_LAUNCH_CRASHED_AUTOMATIC,
|
||||||
|
GEODE_NOT_FOUND -> true
|
||||||
|
GAME_MISSING,
|
||||||
|
GAME_OUTDATED -> false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isCrash() = when (this) {
|
||||||
|
LAST_LAUNCH_CRASHED,
|
||||||
|
LAST_LAUNCH_CRASHED_AUTOMATIC -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isGameInstallIssue() = when (this) {
|
||||||
|
GAME_OUTDATED,
|
||||||
|
GAME_MISSING -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class LaunchUIState {
|
||||||
|
data object Initial : LaunchUIState()
|
||||||
|
data object UpdateCheck : LaunchUIState()
|
||||||
|
data class Updating(val downloaded: Long, val outOf: Long?) : LaunchUIState()
|
||||||
|
data class Cancelled(val reason: LaunchCancelReason, val inProgress: Boolean = false) : LaunchUIState()
|
||||||
|
data object Working : LaunchUIState()
|
||||||
|
data object Ready : LaunchUIState()
|
||||||
|
|
||||||
|
fun isInProgress() = when (this) {
|
||||||
|
is Ready,
|
||||||
|
is Working,
|
||||||
|
is Updating,
|
||||||
|
is UpdateCheck -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow<LaunchUIState>(LaunchUIState.Initial)
|
||||||
|
val uiState = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
val nextLauncherUpdate = ReleaseManager.get(application).availableLauncherUpdateTag
|
||||||
|
|
||||||
|
var loadFailure: LoadFailureInfo? = null
|
||||||
|
var launchArguments: LaunchArguments? = null
|
||||||
|
|
||||||
|
private var hasCheckedForUpdates = false
|
||||||
|
private var isCancelling = false
|
||||||
|
private var hasManuallyStarted = false
|
||||||
|
|
||||||
|
private var restoreUpdateState: LaunchUIState? = null
|
||||||
|
|
||||||
|
private suspend fun determineUpdateStatus() {
|
||||||
|
ReleaseManager.get(application).uiState.takeWhile {
|
||||||
|
it is ReleaseManager.ReleaseManagerState.InUpdateCheck || it is ReleaseManager.ReleaseManagerState.InDownload
|
||||||
|
}.map { when (it) {
|
||||||
|
is ReleaseManager.ReleaseManagerState.InUpdateCheck -> LaunchUIState.UpdateCheck
|
||||||
|
is ReleaseManager.ReleaseManagerState.InDownload -> LaunchUIState.Updating(it.downloaded, it.outOf)
|
||||||
|
else -> LaunchUIState.UpdateCheck
|
||||||
|
}
|
||||||
|
}.collect {
|
||||||
|
_uiState.emit(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
// flow terminates once update check is finished
|
||||||
|
hasCheckedForUpdates = true
|
||||||
|
|
||||||
|
val currentState = restoreUpdateState
|
||||||
|
if (currentState != null) {
|
||||||
|
_uiState.emit(currentState)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
preReadyCheck()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun currentCrashInfo(): LoadFailureInfo? {
|
||||||
|
val currentState = _uiState.value
|
||||||
|
if (currentState is LaunchUIState.Cancelled && currentState.reason.isCrash()) {
|
||||||
|
return loadFailure
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearCrashInfo() {
|
||||||
|
val currentState = _uiState.value
|
||||||
|
if (currentState is LaunchUIState.Cancelled && currentState.reason.isCrash()) {
|
||||||
|
loadFailure = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun preReadyCheck() {
|
||||||
|
if (isCancelling) {
|
||||||
|
// don't let the game start if cancelled
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val packageManager = application.packageManager
|
||||||
|
|
||||||
|
if (!GamePackageUtils.isGameInstalled(packageManager)) {
|
||||||
|
_uiState.tryEmit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_MISSING))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ReleaseManager.get(application).isInUpdate) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
determineUpdateStatus()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// play store build only comes with the one build of geode
|
||||||
|
val minVersionCode = if (BuildConfig.GOOGLE_PLAY_BUILD) Constants.SUPPORTED_VERSION_CODE else Constants.SUPPORTED_VERSION_CODE_MIN
|
||||||
|
if (GamePackageUtils.getGameVersionCode(packageManager) < minVersionCode) {
|
||||||
|
_uiState.tryEmit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_OUTDATED))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!LaunchUtils.isGeodeInstalled(application)) {
|
||||||
|
_uiState.tryEmit(LaunchUIState.Cancelled(LaunchCancelReason.GEODE_NOT_FOUND))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// if forcing immediate launch, then act as if it's manually started (skips timers)
|
||||||
|
val forceImmediate = launchArguments?.forceLaunch == true
|
||||||
|
|
||||||
|
// last launch crashed, so cancel
|
||||||
|
if (!forceImmediate && loadFailure != null) {
|
||||||
|
// show a different message if this is the first thing users see
|
||||||
|
val cancelType = if (_uiState.value == LaunchUIState.Initial) {
|
||||||
|
LaunchCancelReason.LAST_LAUNCH_CRASHED_AUTOMATIC
|
||||||
|
} else LaunchCancelReason.LAST_LAUNCH_CRASHED
|
||||||
|
|
||||||
|
_uiState.tryEmit(LaunchUIState.Cancelled(cancelType))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (forceImmediate) {
|
||||||
|
hasManuallyStarted = true
|
||||||
|
}
|
||||||
|
|
||||||
|
val loadAutomatically = PreferenceUtils.get(application).getBoolean(PreferenceUtils.Key.LOAD_AUTOMATICALLY)
|
||||||
|
val forcePause = launchArguments?.forcePause == true
|
||||||
|
if (!hasManuallyStarted && (forcePause || !loadAutomatically)) {
|
||||||
|
return cancelLaunch(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!readyTimerPassed && !hasManuallyStarted) {
|
||||||
|
_uiState.tryEmit(LaunchUIState.Working)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.tryEmit(LaunchUIState.Ready)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun beginLaunchFlow(isRestart: Boolean = false) {
|
||||||
|
if (isRestart) {
|
||||||
|
hasManuallyStarted = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_uiState.value !is LaunchUIState.Initial && _uiState.value !is LaunchUIState.Cancelled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_uiState.value is LaunchUIState.Cancelled && !isRestart) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isCancelling = false
|
||||||
|
|
||||||
|
if (!GamePackageUtils.isGameInstalled(application.packageManager)) {
|
||||||
|
_uiState.tryEmit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_MISSING))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (readyTimer == null) {
|
||||||
|
initReadyTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!BuildConfig.GOOGLE_PLAY_BUILD) {
|
||||||
|
val releaseManager = ReleaseManager.get(application)
|
||||||
|
|
||||||
|
val hasGeode = LaunchUtils.isGeodeInstalled(application)
|
||||||
|
val shouldUpdate = PreferenceUtils.get(application).getBoolean(PreferenceUtils.Key.UPDATE_AUTOMATICALLY)
|
||||||
|
|
||||||
|
if ((shouldUpdate && !hasCheckedForUpdates) || !hasGeode) {
|
||||||
|
releaseManager.checkForUpdates(false)
|
||||||
|
} else {
|
||||||
|
releaseManager.afterUseCachedUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRestart) {
|
||||||
|
releaseManager.dismissedLauncherUpdate = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
preReadyCheck()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun retryUpdate() {
|
||||||
|
if (BuildConfig.GOOGLE_PLAY_BUILD) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ReleaseManager.get(application).checkForUpdates(true)
|
||||||
|
if (ReleaseManager.get(application).isInUpdate) {
|
||||||
|
restoreUpdateState = _uiState.value
|
||||||
|
viewModelScope.launch {
|
||||||
|
determineUpdateStatus()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelLaunch(isAutomatic: Boolean = false) {
|
||||||
|
// no need to double cancel
|
||||||
|
if (_uiState.value is LaunchUIState.Cancelled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val reason = if (isAutomatic) LaunchCancelReason.AUTOMATIC else LaunchCancelReason.MANUAL
|
||||||
|
|
||||||
|
isCancelling = true
|
||||||
|
|
||||||
|
val releaseManager = ReleaseManager.get(application)
|
||||||
|
if (releaseManager.isInUpdate) {
|
||||||
|
_uiState.tryEmit(LaunchUIState.Cancelled(reason, true))
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
releaseManager.cancelUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.tryEmit(LaunchUIState.Cancelled(reason, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
super.onCleared()
|
||||||
|
|
||||||
|
readyTimer?.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
package com.geode.launcher.main
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageInstaller
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ElevatedCard
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextLinkStyles
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.geode.launcher.InstallReceiver
|
||||||
|
import com.geode.launcher.R
|
||||||
|
import com.geode.launcher.main.LauncherUpdater.downloadUpdate
|
||||||
|
import com.geode.launcher.ui.theme.Typography
|
||||||
|
import com.geode.launcher.updater.ReleaseManager
|
||||||
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
|
import com.mikepenz.markdown.compose.LocalBulletListHandler
|
||||||
|
import com.mikepenz.markdown.m3.Markdown
|
||||||
|
import com.mikepenz.markdown.m3.markdownTypography
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor
|
||||||
|
import java.io.File
|
||||||
|
import java.text.DateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import kotlin.time.toJavaInstant
|
||||||
|
|
||||||
|
fun clearDownloadedApks(context: Context) {
|
||||||
|
// technically we should be using the activity results but it was too inconsistent for my liking
|
||||||
|
val preferenceUtils = PreferenceUtils.get(context)
|
||||||
|
val performCleanup = preferenceUtils.getBoolean(PreferenceUtils.Key.CLEANUP_APKS)
|
||||||
|
if (!performCleanup) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val baseDirectory = LaunchUtils.getBaseDirectory(context)
|
||||||
|
|
||||||
|
baseDirectory.listFiles {
|
||||||
|
// only select apk files
|
||||||
|
_, name -> name.lowercase().endsWith(".apk")
|
||||||
|
}?.forEach {
|
||||||
|
it.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
context.filesDir.listFiles { _, name -> name.lowercase().endsWith(".apk") }?.forEach {
|
||||||
|
it.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
preferenceUtils.setBoolean(PreferenceUtils.Key.CLEANUP_APKS, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// lazy singleton idc
|
||||||
|
object LauncherUpdater {
|
||||||
|
enum class LauncherUpdateState {
|
||||||
|
Began,
|
||||||
|
Downloading,
|
||||||
|
Active,
|
||||||
|
Inactive,
|
||||||
|
Finished;
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow<LauncherUpdateState>(LauncherUpdateState.Inactive)
|
||||||
|
val uiState = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
suspend fun resetState() {
|
||||||
|
_uiState.emit(LauncherUpdateState.Inactive)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun downloadUpdate(context: Context): Result<File> {
|
||||||
|
_uiState.emit(LauncherUpdateState.Downloading)
|
||||||
|
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
ReleaseManager.get(context)
|
||||||
|
.downloadLatestLauncherUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun installPackage(context: Context, file: File) {
|
||||||
|
_uiState.emit(LauncherUpdateState.Began)
|
||||||
|
|
||||||
|
val installer = context.packageManager.packageInstaller
|
||||||
|
|
||||||
|
installer.registerSessionCallback(object : PackageInstaller.SessionCallback() {
|
||||||
|
override fun onActiveChanged(sessionId: Int, active: Boolean) {
|
||||||
|
if (active) {
|
||||||
|
_uiState.tryEmit(LauncherUpdateState.Active)
|
||||||
|
} else {
|
||||||
|
_uiState.tryEmit(LauncherUpdateState.Began)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBadgingChanged(sessionId: Int) {}
|
||||||
|
|
||||||
|
override fun onCreated(sessionId: Int) {
|
||||||
|
_uiState.tryEmit(LauncherUpdateState.Began)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFinished(sessionId: Int, success: Boolean) {
|
||||||
|
_uiState.tryEmit(LauncherUpdateState.Finished)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onProgressChanged(sessionId: Int, progress: Float) {
|
||||||
|
_uiState.tryEmit(LauncherUpdateState.Active)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
withContext(Dispatchers.IO) { runInterruptible {
|
||||||
|
file.inputStream().use { apkStream ->
|
||||||
|
val length = file.length()
|
||||||
|
|
||||||
|
val params = PackageInstaller.SessionParams(
|
||||||
|
PackageInstaller.SessionParams.MODE_FULL_INSTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
val sessionId = installer.createSession(params)
|
||||||
|
installer.openSession(sessionId).use { session ->
|
||||||
|
session.openWrite("package", 0, length).use { sessionStream ->
|
||||||
|
apkStream.copyTo(sessionStream)
|
||||||
|
session.fsync(sessionStream)
|
||||||
|
}
|
||||||
|
|
||||||
|
val intent = Intent(context, InstallReceiver::class.java)
|
||||||
|
val pi = PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
0,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
session.commit(pi.intentSender)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun installLauncherUpdate(context: Context) {
|
||||||
|
val launcherDownload = downloadUpdate(context)
|
||||||
|
.getOrElse { e ->
|
||||||
|
e.printStackTrace()
|
||||||
|
Toast.makeText(
|
||||||
|
context, context.getString(
|
||||||
|
R.string.launcher_update_download_failed,
|
||||||
|
e.cause
|
||||||
|
), Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!launcherDownload.exists()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
PreferenceUtils.get(context).setBoolean(PreferenceUtils.Key.CLEANUP_APKS, true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
LauncherUpdater.installPackage(context, launcherDownload)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
val msg = context.getString(R.string.launcher_self_update_failed, e.message)
|
||||||
|
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
||||||
|
|
||||||
|
LauncherUpdater.resetState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LauncherUpdateDialog(isCancelling: Boolean, onDismiss: () -> Unit) {
|
||||||
|
val updateState by LauncherUpdater.uiState.collectAsState()
|
||||||
|
|
||||||
|
LaunchedEffect(updateState) {
|
||||||
|
if (updateState == LauncherUpdater.LauncherUpdateState.Finished) {
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_update),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
if (isCancelling) {
|
||||||
|
Text(stringResource(R.string.release_fetch_cancelling))
|
||||||
|
} else if (updateState == LauncherUpdater.LauncherUpdateState.Downloading) {
|
||||||
|
Text(stringResource(R.string.launcher_downloading_update))
|
||||||
|
} else {
|
||||||
|
Text(stringResource(R.string.launcher_self_update_progress))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
LinearProgressIndicator()
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss, enabled = !isCancelling) {
|
||||||
|
Text(stringResource(R.string.message_box_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismissRequest = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun LauncherUpdateInformation(onDismiss: () -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
val releaseManager = remember { ReleaseManager.get(context) }
|
||||||
|
|
||||||
|
val nextReleaseName by releaseManager.availableLauncherUpdateTag.collectAsState()
|
||||||
|
val nextRelease by releaseManager.availableLauncherUpdateDetails.collectAsState()
|
||||||
|
|
||||||
|
val sheetState = rememberModalBottomSheetState()
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
var updateJob by remember { mutableStateOf<Job?>(null) }
|
||||||
|
var cancellingUpdate by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
var beginUpdate by remember { mutableStateOf(false) }
|
||||||
|
if (beginUpdate) {
|
||||||
|
LauncherUpdateDialog(cancellingUpdate, onDismiss = {
|
||||||
|
cancellingUpdate = true
|
||||||
|
|
||||||
|
coroutineScope.launch {
|
||||||
|
updateJob?.cancelAndJoin()
|
||||||
|
|
||||||
|
beginUpdate = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (nextReleaseName != null && nextRelease == null) {
|
||||||
|
coroutineScope.launch { withContext(Dispatchers.IO) {
|
||||||
|
ReleaseManager.get(context)
|
||||||
|
.checkLauncherUpdate()
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val nextReleaseNameSaved = nextReleaseName
|
||||||
|
if (nextReleaseNameSaved != null) {
|
||||||
|
ModalBottomSheet(onDismissRequest = { onDismiss() }, sheetState = sheetState) {
|
||||||
|
Box(Modifier.verticalScroll(rememberScrollState())) {
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 24.dp)) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.launcher_update_name, nextReleaseNameSaved),
|
||||||
|
style = Typography.headlineSmall,
|
||||||
|
)
|
||||||
|
|
||||||
|
val nextRelease = nextRelease
|
||||||
|
if (nextRelease != null) {
|
||||||
|
val releasedTime = remember {
|
||||||
|
DateFormat.getDateInstance().format(
|
||||||
|
Date.from(nextRelease.release.publishedAt.toJavaInstant())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.launcher_update_released, "$releasedTime"),
|
||||||
|
style = Typography.labelLarge
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(modifier = Modifier.padding(vertical = 12.dp)) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
updateJob = coroutineScope.launch {
|
||||||
|
cancellingUpdate = false
|
||||||
|
beginUpdate = true
|
||||||
|
|
||||||
|
installLauncherUpdate(context)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Icon(painterResource(R.drawable.icon_download), contentDescription = null)
|
||||||
|
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||||
|
Text(stringResource(R.string.launcher_install))
|
||||||
|
}
|
||||||
|
|
||||||
|
val uriHandler = LocalUriHandler.current
|
||||||
|
|
||||||
|
IconButton(onClick = { uriHandler.openUri(nextRelease.release.htmlUrl) }) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_link),
|
||||||
|
stringResource(R.string.launcher_update_external_link)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextRelease.release.body != null) {
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.launcher_update_changelog),
|
||||||
|
style = Typography.titleLarge
|
||||||
|
)
|
||||||
|
|
||||||
|
// every new release of the markdown library adds another arg to it
|
||||||
|
CompositionLocalProvider(LocalBulletListHandler provides { _, _, _, _, _ -> "• " }) {
|
||||||
|
Markdown(
|
||||||
|
content = nextRelease.release.body.replace("\r", ""),
|
||||||
|
typography = markdownTypography(
|
||||||
|
textLink = TextLinkStyles(
|
||||||
|
style = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
textDecoration = TextDecoration.Underline,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
).toSpanStyle()
|
||||||
|
),
|
||||||
|
),
|
||||||
|
flavour = GFMFlavourDescriptor(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 12.dp)
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.size(4.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LauncherUpdateIndicator(modifier: Modifier = Modifier) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
var showInfoDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
if (showInfoDialog) {
|
||||||
|
LauncherUpdateInformation {
|
||||||
|
showInfoDialog = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var updateJob by remember { mutableStateOf<Job?>(null) }
|
||||||
|
var cancellingUpdate by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
var beginUpdate by remember { mutableStateOf(false) }
|
||||||
|
if (beginUpdate) {
|
||||||
|
LauncherUpdateDialog(cancellingUpdate) {
|
||||||
|
cancellingUpdate = true
|
||||||
|
coroutineScope.launch {
|
||||||
|
updateJob?.cancelAndJoin()
|
||||||
|
updateJob = null
|
||||||
|
|
||||||
|
beginUpdate = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// there's like a single pixel of the shadow that gets clipped without this...
|
||||||
|
ElevatedCard(modifier = modifier.padding(bottom = 1.dp)) {
|
||||||
|
Column(
|
||||||
|
// buttons add enough padding already, lower to compensate
|
||||||
|
modifier = Modifier.padding(
|
||||||
|
top = 20.dp,
|
||||||
|
start = 12.dp,
|
||||||
|
end = 12.dp,
|
||||||
|
bottom = 8.dp
|
||||||
|
),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.launcher_update_available),
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(modifier = Modifier.align(Alignment.End)) {
|
||||||
|
TextButton(
|
||||||
|
onClick = { showInfoDialog = true },
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.launcher_update_view))
|
||||||
|
}
|
||||||
|
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
updateJob = coroutineScope.launch {
|
||||||
|
cancellingUpdate = false
|
||||||
|
beginUpdate = true
|
||||||
|
|
||||||
|
installLauncherUpdate(context)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.launcher_install))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
package com.geode.launcher.preferences
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.OnBackPressedDispatcher
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.geode.launcher.R
|
||||||
|
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||||
|
import com.geode.launcher.ui.theme.LocalTheme
|
||||||
|
import com.geode.launcher.ui.theme.Theme
|
||||||
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import com.geode.launcher.utils.ApplicationIcon
|
||||||
|
import com.geode.launcher.utils.ApplicationIconDetails
|
||||||
|
import com.geode.launcher.utils.IconUtils
|
||||||
|
import com.geode.launcher.utils.adaptiveIconPainterResource
|
||||||
|
|
||||||
|
class ApplicationIconActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContent {
|
||||||
|
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
|
||||||
|
val theme = Theme.fromInt(themeOption)
|
||||||
|
|
||||||
|
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
|
||||||
|
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalTheme provides theme) {
|
||||||
|
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
ApplicationIconScreen(onBackPressedDispatcher)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun IconSelectConfirmDialog(selectedIcon: ApplicationIconDetails, onDismissRequest: () -> Unit) {
|
||||||
|
AlertDialog(
|
||||||
|
icon = {
|
||||||
|
Image(
|
||||||
|
adaptiveIconPainterResource(selectedIcon.iconId),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(stringResource(
|
||||||
|
R.string.application_icon_confirm,
|
||||||
|
stringResource(selectedIcon.nameId)
|
||||||
|
))
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(stringResource(R.string.application_icon_confirm_description))
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismissRequest) {
|
||||||
|
Text(stringResource(R.string.message_box_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
TextButton(onClick = { onSelectIcon(context, selectedIcon) }) {
|
||||||
|
Text(stringResource(R.string.message_box_accept))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismissRequest = onDismissRequest
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSelectIcon(context: Context, selectedIcon: ApplicationIconDetails) {
|
||||||
|
val packageManager = context.packageManager
|
||||||
|
|
||||||
|
try {
|
||||||
|
ApplicationIcon.entries.forEach {
|
||||||
|
val iconData = IconUtils.getIconDetails(it)
|
||||||
|
|
||||||
|
packageManager.setComponentEnabledSetting(
|
||||||
|
ComponentName(context, "${context.packageName.removeSuffix(".play")}.${iconData.component}"),
|
||||||
|
if (iconData.id == selectedIcon.id)
|
||||||
|
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||||
|
else PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
|
||||||
|
PackageManager.DONT_KILL_APP
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: IllegalArgumentException) {
|
||||||
|
Toast.makeText(context, R.string.application_icon_set_failed, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
PreferenceUtils.get(context).setString(PreferenceUtils.Key.SELECTED_ICON, selectedIcon.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun IconCard(iconData: ApplicationIconDetails, selected: Boolean, modifier: Modifier = Modifier) {
|
||||||
|
var showSelection by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
if (showSelection) {
|
||||||
|
IconSelectConfirmDialog(iconData) {
|
||||||
|
showSelection = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 1.dp)
|
||||||
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.then(modifier)
|
||||||
|
.selectable(
|
||||||
|
selected = selected,
|
||||||
|
onClick = {
|
||||||
|
if (!selected)
|
||||||
|
showSelection = true
|
||||||
|
},
|
||||||
|
role = Role.RadioButton
|
||||||
|
)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
Arrangement.SpaceBetween,
|
||||||
|
Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
adaptiveIconPainterResource(iconData.iconId),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
Text(stringResource(iconData.nameId))
|
||||||
|
}
|
||||||
|
|
||||||
|
RadioButton(
|
||||||
|
selected = selected,
|
||||||
|
onClick = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ApplicationIconScreen(
|
||||||
|
onBackPressedDispatcher: OnBackPressedDispatcher?,
|
||||||
|
) {
|
||||||
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
|
topBar = {
|
||||||
|
Column {
|
||||||
|
TopAppBar(
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_arrow_back),
|
||||||
|
contentDescription = stringResource(R.string.back_icon_alt)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.title_activity_application_icon),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
},
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.SpaceEvenly,
|
||||||
|
) {
|
||||||
|
val selectedIcon by PreferenceUtils.useStringPreference(PreferenceUtils.Key.SELECTED_ICON)
|
||||||
|
|
||||||
|
Column(Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
) {
|
||||||
|
ApplicationIcon.entries.forEach {
|
||||||
|
val iconData = IconUtils.getIconDetails(it)
|
||||||
|
IconCard(iconData, selectedIcon == iconData.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
package com.geode.launcher.preferences
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.OnBackPressedDispatcher
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalResources
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.geode.launcher.R
|
||||||
|
import com.geode.launcher.log.LogLine
|
||||||
|
import com.geode.launcher.log.LogPriority
|
||||||
|
import com.geode.launcher.log.LogViewModel
|
||||||
|
import com.geode.launcher.preferences.components.SelectDialog
|
||||||
|
import com.geode.launcher.preferences.components.SelectOption
|
||||||
|
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||||
|
import com.geode.launcher.ui.theme.LocalTheme
|
||||||
|
import com.geode.launcher.ui.theme.Theme
|
||||||
|
import com.geode.launcher.ui.theme.Typography
|
||||||
|
import com.geode.launcher.ui.theme.robotoMonoFamily
|
||||||
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class ApplicationLogsActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContent {
|
||||||
|
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
|
||||||
|
val theme = Theme.fromInt(themeOption)
|
||||||
|
|
||||||
|
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
|
||||||
|
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalTheme provides theme) {
|
||||||
|
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
ApplicationLogsScreen(onBackPressedDispatcher)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun getLogColor(priority: LogPriority): Color {
|
||||||
|
val isDark = LocalTheme.current != Theme.LIGHT
|
||||||
|
|
||||||
|
// i just copied these from my ide tbh
|
||||||
|
return if (isDark) {
|
||||||
|
when (priority) {
|
||||||
|
LogPriority.ERROR -> Color(0xff_df_51_51)
|
||||||
|
LogPriority.WARN -> Color(0xff_bc_b5_00)
|
||||||
|
LogPriority.FATAL -> Color(0xff_b8_b6_fc)
|
||||||
|
LogPriority.DEBUG -> Color(0xff_aa_aa_aa)
|
||||||
|
LogPriority.VERBOSE -> Color(0xff_77_77_77)
|
||||||
|
else -> Color.Unspecified
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
when (priority) {
|
||||||
|
LogPriority.ERROR -> Color(0xff_df_51_51)
|
||||||
|
LogPriority.WARN -> Color(0xff_ea_b7_00)
|
||||||
|
LogPriority.FATAL -> Color(0xff_a1_42_f4)
|
||||||
|
LogPriority.DEBUG -> Color(0xff_77_77_77)
|
||||||
|
LogPriority.VERBOSE -> Color(0xff_aa_aa_aa)
|
||||||
|
else -> Color.Unspecified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SelectLogLevelDialog(
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onSelect: (LogPriority) -> Unit,
|
||||||
|
initialValue: LogPriority
|
||||||
|
) {
|
||||||
|
SelectDialog(
|
||||||
|
title = stringResource(R.string.application_logs_level_dialog_name),
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
onSelect = { onSelect(it) },
|
||||||
|
initialValue = initialValue,
|
||||||
|
) {
|
||||||
|
(LogPriority.VERBOSE..LogPriority.FATAL).reversed().forEach {
|
||||||
|
SelectOption(
|
||||||
|
name = it.name.lowercase().replaceFirstChar { c -> c.uppercaseChar() },
|
||||||
|
value = it
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LogCard(logLine: LogLine, modifier: Modifier = Modifier) {
|
||||||
|
SelectionContainer {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.padding(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"[${logLine.priority.toChar()}] ${logLine.formattedTime}",
|
||||||
|
style = Typography.labelSmall,
|
||||||
|
color = getLogColor(logLine.priority),
|
||||||
|
fontFamily = robotoMonoFamily
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"[${logLine.tag}]: ${logLine.messageTrimmed}",
|
||||||
|
fontFamily = robotoMonoFamily,
|
||||||
|
style = Typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ApplicationLogsScreen(
|
||||||
|
onBackPressedDispatcher: OnBackPressedDispatcher?,
|
||||||
|
logViewModel: LogViewModel = viewModel()
|
||||||
|
) {
|
||||||
|
val logLines = logViewModel.lineState
|
||||||
|
val isLoading by logViewModel.isLoading.collectAsState()
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
val resources = LocalResources.current
|
||||||
|
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
var showMoreOptions by remember { mutableStateOf(false) }
|
||||||
|
val saveLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri ->
|
||||||
|
if (uri != null) {
|
||||||
|
coroutineScope.launch {
|
||||||
|
val logs = logViewModel.getLogData()
|
||||||
|
|
||||||
|
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||||
|
outputStream.bufferedWriter().use { writer ->
|
||||||
|
writer.write(logs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Toast.makeText(context, R.string.application_logs_export_completed, Toast.LENGTH_SHORT)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(logLines.size) {
|
||||||
|
listState.scrollToItem(logLines.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
var showLogLevelSelect by remember { mutableStateOf(false) }
|
||||||
|
if (showLogLevelSelect) {
|
||||||
|
SelectLogLevelDialog(
|
||||||
|
onDismissRequest = { showLogLevelSelect = false },
|
||||||
|
onSelect = {
|
||||||
|
logViewModel.logLevel = it
|
||||||
|
showLogLevelSelect = false
|
||||||
|
},
|
||||||
|
initialValue = logViewModel.logLevel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
|
topBar = {
|
||||||
|
Column {
|
||||||
|
TopAppBar(
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_arrow_back),
|
||||||
|
contentDescription = stringResource(R.string.back_icon_alt)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
saveLauncher.launch(resources.getString(R.string.application_logs_default_filename))
|
||||||
|
}
|
||||||
|
}, enabled = !logLines.isEmpty()) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_save),
|
||||||
|
contentDescription = stringResource(R.string.application_logs_export),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = { showMoreOptions = !showMoreOptions }) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_more_vert),
|
||||||
|
contentDescription = stringResource(R.string.application_logs_more)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showMoreOptions,
|
||||||
|
onDismissRequest = { showMoreOptions = false }
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_bug_report),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
if (logViewModel.filterCrashes) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_check),
|
||||||
|
contentDescription = stringResource(R.string.application_logs_crash_only_enabled_alt)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(stringResource(R.string.application_logs_crash_only))
|
||||||
|
}, onClick = {
|
||||||
|
logViewModel.toggleCrashBuffer()
|
||||||
|
showMoreOptions = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
DropdownMenuItem(
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_filter_list),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(stringResource(R.string.application_logs_level))
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
showLogLevelSelect = true
|
||||||
|
showMoreOptions = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
DropdownMenuItem(
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_delete),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(stringResource(R.string.application_logs_delete))
|
||||||
|
}, onClick = {
|
||||||
|
logViewModel.clearLogs()
|
||||||
|
showMoreOptions = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.title_activity_application_logs),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
},
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
LazyColumn(
|
||||||
|
Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.SpaceEvenly,
|
||||||
|
state = listState
|
||||||
|
) {
|
||||||
|
if (!isLoading && logLines.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.application_logs_no_logs),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
style = Typography.bodyLarge,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items(
|
||||||
|
logLines
|
||||||
|
) { line ->
|
||||||
|
LogCard(line)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showSystemUi = true)
|
||||||
|
@Composable
|
||||||
|
fun LogsPreview() {
|
||||||
|
GeodeLauncherTheme {
|
||||||
|
ApplicationLogsScreen(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
package com.geode.launcher.preferences
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
import android.widget.Toast
|
||||||
|
import java.text.DateFormat
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.OnBackPressedDispatcher
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.geode.launcher.R
|
||||||
|
import com.geode.launcher.UserDirectoryProvider
|
||||||
|
import com.geode.launcher.log.CrashDump
|
||||||
|
import com.geode.launcher.log.CrashViewModel
|
||||||
|
import com.geode.launcher.preferences.components.DirectoryListingScreen
|
||||||
|
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||||
|
import com.geode.launcher.ui.theme.LocalTheme
|
||||||
|
import com.geode.launcher.ui.theme.Theme
|
||||||
|
import com.geode.launcher.ui.theme.robotoMonoFamily
|
||||||
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
|
import java.io.File
|
||||||
|
import java.util.Date
|
||||||
|
import kotlin.time.toJavaInstant
|
||||||
|
|
||||||
|
class CrashLogsActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContent {
|
||||||
|
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
|
||||||
|
val theme = Theme.fromInt(themeOption)
|
||||||
|
|
||||||
|
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
|
||||||
|
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalTheme provides theme) {
|
||||||
|
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
CrashLogsScreen(onBackPressedDispatcher)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shareCrash(context: Context, filename: String) {
|
||||||
|
val baseDirectory = LaunchUtils.getBaseDirectory(context, true)
|
||||||
|
val crashPath = File(LaunchUtils.getCrashDirectory(context), filename)
|
||||||
|
|
||||||
|
if (!crashPath.exists()) {
|
||||||
|
Toast.makeText(context, R.string.launcher_error_export_missing, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val providerLocation = crashPath.toRelativeString(baseDirectory)
|
||||||
|
val documentsPath = "${UserDirectoryProvider.ROOT}${providerLocation}"
|
||||||
|
|
||||||
|
val uri = DocumentsContract.buildDocumentUri("${context.packageName}.user", documentsPath)
|
||||||
|
|
||||||
|
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||||
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
|
type = "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
context.startActivity(shareIntent)
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
Toast.makeText(context, R.string.no_activity_found, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CrashCard(crashDump: CrashDump, crashViewModel: CrashViewModel, modifier: Modifier = Modifier) {
|
||||||
|
val isReadable = crashDump.filename.endsWith(".log")
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 1.dp, horizontal = 8.dp)
|
||||||
|
.height(64.dp)
|
||||||
|
.then(modifier)
|
||||||
|
.clickable(isReadable) {
|
||||||
|
val launchIntent = Intent(context, TextViewActivity::class.java).run {
|
||||||
|
putExtra(TextViewActivity.EXTRA_LOG_VIEW_FILENAME, crashDump.fullPath)
|
||||||
|
}
|
||||||
|
context.startActivity(launchIntent)
|
||||||
|
}
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerLow)
|
||||||
|
.padding(vertical = 8.dp, horizontal = 8.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.SpaceAround,
|
||||||
|
modifier = Modifier.fillMaxHeight().offset(x = 12.dp).weight(0.7f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
crashDump.filename,
|
||||||
|
fontFamily = robotoMonoFamily,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
val formattedLastModified = remember {
|
||||||
|
DateFormat.getDateTimeInstance()
|
||||||
|
.format(
|
||||||
|
Date.from(crashDump.lastModified.toJavaInstant())
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
Text(formattedLastModified, style = MaterialTheme.typography.labelMedium)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(modifier = Modifier.weight(0.3f), horizontalArrangement = Arrangement.End) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
IconButton(onClick = {
|
||||||
|
shareCrash(context, crashDump.filename)
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_share),
|
||||||
|
contentDescription = stringResource(R.string.application_logs_share)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(onClick = {
|
||||||
|
crashViewModel.removeFile(crashDump.filename)
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_delete),
|
||||||
|
contentDescription = stringResource(R.string.application_crashes_delete_one)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CrashLogsScreen(
|
||||||
|
onBackPressedDispatcher: OnBackPressedDispatcher?,
|
||||||
|
crashViewModel: CrashViewModel = viewModel(factory = CrashViewModel.Factory)
|
||||||
|
) {
|
||||||
|
DirectoryListingScreen(
|
||||||
|
onBackPressedDispatcher,
|
||||||
|
crashViewModel,
|
||||||
|
titleId = R.string.title_activity_crash_logs,
|
||||||
|
noneAvailableId = R.string.application_crashes_no_dumps,
|
||||||
|
additionalOptions = { onDismiss ->
|
||||||
|
val developerMode by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DEVELOPER_MODE)
|
||||||
|
if (developerMode) {
|
||||||
|
if (crashViewModel.hasIndicator) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_remove_selection),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(stringResource(R.string.application_crashes_clear_indicator))
|
||||||
|
}, onClick = {
|
||||||
|
crashViewModel.clearIndicator()
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
DropdownMenuItem(
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_skull),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(stringResource(R.string.application_crashes_create_indicator))
|
||||||
|
}, onClick = {
|
||||||
|
crashViewModel.createIndicator()
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { line, shape ->
|
||||||
|
CrashCard(
|
||||||
|
line,
|
||||||
|
crashViewModel,
|
||||||
|
modifier = Modifier.clip(shape)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showSystemUi = true)
|
||||||
|
@Composable
|
||||||
|
fun CrashLogsPreview() {
|
||||||
|
GeodeLauncherTheme {
|
||||||
|
CrashLogsScreen(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
package com.geode.launcher.preferences
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.OnBackPressedDispatcher
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.geode.launcher.BuildConfig
|
||||||
|
import com.geode.launcher.R
|
||||||
|
import com.geode.launcher.preferences.components.OptionsButton
|
||||||
|
import com.geode.launcher.preferences.components.OptionsGroup
|
||||||
|
import com.geode.launcher.preferences.components.ProfileCreateCard
|
||||||
|
import com.geode.launcher.preferences.components.ProfileSelectCard
|
||||||
|
import com.geode.launcher.preferences.components.SettingsCard
|
||||||
|
import com.geode.launcher.preferences.components.SettingsSelectCard
|
||||||
|
import com.geode.launcher.preferences.components.SettingsStringCard
|
||||||
|
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||||
|
import com.geode.launcher.ui.theme.LocalTheme
|
||||||
|
import com.geode.launcher.ui.theme.Theme
|
||||||
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
|
|
||||||
|
class DeveloperSettingsActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContent {
|
||||||
|
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
|
||||||
|
val theme = Theme.fromInt(themeOption)
|
||||||
|
|
||||||
|
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
|
||||||
|
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalTheme provides theme) {
|
||||||
|
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
DeveloperSettingsScreen(onBackPressedDispatcher)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onOpenFolder(context: Context) {
|
||||||
|
val file = LaunchUtils.getBaseDirectory(context)
|
||||||
|
val clipboardManager =
|
||||||
|
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
clipboardManager.setPrimaryClip(
|
||||||
|
ClipData.newPlainText(
|
||||||
|
context.getString(R.string.export_folder_tag),
|
||||||
|
file.path
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
context.getString(R.string.text_copied),
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onOpenLogs(context: Context) {
|
||||||
|
val launchIntent = Intent(context, ApplicationLogsActivity::class.java)
|
||||||
|
context.startActivity(launchIntent)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
|
||||||
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
|
topBar = {
|
||||||
|
Column {
|
||||||
|
TopAppBar(
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_arrow_back),
|
||||||
|
contentDescription = stringResource(R.string.back_icon_alt)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.title_activity_developer_settings),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
},
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(MaterialTheme.colorScheme.background),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
|
||||||
|
val developerMode by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DEVELOPER_MODE)
|
||||||
|
|
||||||
|
OptionsGroup {
|
||||||
|
SettingsCard(
|
||||||
|
title = stringResource(R.string.preference_developer_mode),
|
||||||
|
preferenceKey = PreferenceUtils.Key.DEVELOPER_MODE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (developerMode) {
|
||||||
|
OptionsGroup(title = stringResource(R.string.preference_category_gameplay)) {
|
||||||
|
SettingsStringCard(
|
||||||
|
title = stringResource(R.string.preference_launch_arguments_name),
|
||||||
|
dialogTitle = stringResource(R.string.preference_launch_arguments_set_title),
|
||||||
|
preferenceKey = PreferenceUtils.Key.LAUNCH_ARGUMENTS,
|
||||||
|
filterInput = {
|
||||||
|
it.filter { c ->
|
||||||
|
// if only there was a better way to define this!
|
||||||
|
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890!@#$%^&*(){}<>[]?:;'\"~`-_+=\\| ".contains(
|
||||||
|
c
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
SettingsCard(
|
||||||
|
title = stringResource(R.string.preference_force_hrr),
|
||||||
|
preferenceKey = PreferenceUtils.Key.FORCE_HRR,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard(
|
||||||
|
title = stringResource(R.string.preference_override_exceptions_name),
|
||||||
|
description = stringResource(R.string.preference_override_exceptions_description),
|
||||||
|
preferenceKey = PreferenceUtils.Key.CUSTOM_SYMBOL_LIST
|
||||||
|
)
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
OptionsGroup(stringResource(R.string.preference_category_developer)) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
OptionsButton(
|
||||||
|
title = stringResource(R.string.preferences_view_logs),
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_mobile_code),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { onOpenLogs(context) }
|
||||||
|
)
|
||||||
|
|
||||||
|
OptionsButton(
|
||||||
|
title = stringResource(R.string.preferences_copy_external_button),
|
||||||
|
description = LaunchUtils.getBaseDirectory(context).path,
|
||||||
|
onClick = { onOpenFolder(context) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!BuildConfig.GOOGLE_PLAY_BUILD) {
|
||||||
|
OptionsGroup(title = stringResource(R.string.preference_category_updater)) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
SettingsSelectCard(
|
||||||
|
title = stringResource(R.string.preference_release_channel_tag_name),
|
||||||
|
dialogTitle = stringResource(R.string.preference_release_channel_select),
|
||||||
|
preferenceKey = PreferenceUtils.Key.RELEASE_CHANNEL_TAG,
|
||||||
|
options = linkedMapOf(
|
||||||
|
0 to stringResource(R.string.preference_release_channel_stable),
|
||||||
|
1 to stringResource(R.string.preference_release_channel_beta),
|
||||||
|
2 to stringResource(R.string.preference_release_channel_nightly),
|
||||||
|
),
|
||||||
|
extraSelectBehavior = {
|
||||||
|
PreferenceUtils.get(context)
|
||||||
|
.setLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME, 0L)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
SettingsCard(
|
||||||
|
title = stringResource(R.string.preference_disable_update_cache),
|
||||||
|
preferenceKey = PreferenceUtils.Key.DISABLE_UPDATE_CACHE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OptionsGroup(title = stringResource(R.string.preference_category_testing)) {
|
||||||
|
SettingsSelectCard(
|
||||||
|
title = stringResource(R.string.preference_launch_delay),
|
||||||
|
dialogTitle = stringResource(R.string.preference_launch_delay_select),
|
||||||
|
preferenceKey = PreferenceUtils.Key.WAIT_PERIOD,
|
||||||
|
options = linkedMapOf(
|
||||||
|
0 to stringResource(R.string.preference_launch_delay_instant),
|
||||||
|
1 to pluralStringResource(
|
||||||
|
R.plurals.preference_launch_delay_value,
|
||||||
|
1,
|
||||||
|
1
|
||||||
|
),
|
||||||
|
3 to pluralStringResource(
|
||||||
|
R.plurals.preference_launch_delay_value,
|
||||||
|
3,
|
||||||
|
3
|
||||||
|
),
|
||||||
|
5 to pluralStringResource(
|
||||||
|
R.plurals.preference_launch_delay_value,
|
||||||
|
5,
|
||||||
|
5
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
SettingsCard(
|
||||||
|
title = stringResource(R.string.preference_disable_redesign),
|
||||||
|
preferenceKey = PreferenceUtils.Key.DISABLE_REDESIGN,
|
||||||
|
)
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
OptionsGroup(title = stringResource(R.string.preference_profiles)) {
|
||||||
|
ProfileCreateCard()
|
||||||
|
ProfileSelectCard()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package com.geode.launcher.preferences
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
import android.text.format.Formatter
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.OnBackPressedDispatcher
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.geode.launcher.R
|
||||||
|
import com.geode.launcher.UserDirectoryProvider
|
||||||
|
import com.geode.launcher.log.GeodeLog
|
||||||
|
import com.geode.launcher.log.GeodeLogsViewModel
|
||||||
|
import com.geode.launcher.preferences.components.DirectoryListingScreen
|
||||||
|
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||||
|
import com.geode.launcher.ui.theme.LocalTheme
|
||||||
|
import com.geode.launcher.ui.theme.Theme
|
||||||
|
import com.geode.launcher.ui.theme.robotoMonoFamily
|
||||||
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class GeodeLogsListingActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContent {
|
||||||
|
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
|
||||||
|
val theme = Theme.fromInt(themeOption)
|
||||||
|
|
||||||
|
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
|
||||||
|
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalTheme provides theme) {
|
||||||
|
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
GeodeLogsListingScreen(onBackPressedDispatcher)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shareLog(context: Context, filename: String) {
|
||||||
|
val baseDirectory = LaunchUtils.getBaseDirectory(context, true)
|
||||||
|
val crashPath = File(LaunchUtils.getGeodeLogsDirectory(context), filename)
|
||||||
|
|
||||||
|
if (!crashPath.exists()) {
|
||||||
|
Toast.makeText(context, R.string.launcher_error_export_missing, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val providerLocation = crashPath.toRelativeString(baseDirectory)
|
||||||
|
val documentsPath = "${UserDirectoryProvider.ROOT}${providerLocation}"
|
||||||
|
|
||||||
|
val uri = DocumentsContract.buildDocumentUri("${context.packageName}.user", documentsPath)
|
||||||
|
|
||||||
|
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||||
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
|
type = "text/plain"
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
context.startActivity(shareIntent)
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
Toast.makeText(context, R.string.no_activity_found, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GeodeLogCard(geodeLog: GeodeLog, geodeLogsViewModel: GeodeLogsViewModel, modifier: Modifier = Modifier) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 1.dp, horizontal = 8.dp)
|
||||||
|
.height(64.dp)
|
||||||
|
.then(modifier)
|
||||||
|
.clickable {
|
||||||
|
val launchIntent = Intent(context, TextViewActivity::class.java).run {
|
||||||
|
putExtra(TextViewActivity.EXTRA_LOG_VIEW_FILENAME, geodeLog.fullPath)
|
||||||
|
}
|
||||||
|
context.startActivity(launchIntent)
|
||||||
|
}
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerLow)
|
||||||
|
.padding(vertical = 8.dp, horizontal = 8.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.SpaceAround,
|
||||||
|
modifier = Modifier.fillMaxHeight().offset(x = 12.dp).weight(0.7f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
geodeLog.filename,
|
||||||
|
fontFamily = robotoMonoFamily,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
val formattedSize = remember(geodeLog.fileSize) {
|
||||||
|
Formatter.formatShortFileSize(context, geodeLog.fileSize)
|
||||||
|
}
|
||||||
|
Text(formattedSize, style = MaterialTheme.typography.labelMedium)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(modifier = Modifier.weight(0.3f), horizontalArrangement = Arrangement.End) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
IconButton(onClick = {
|
||||||
|
shareLog(context, geodeLog.filename)
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_share),
|
||||||
|
contentDescription = stringResource(R.string.application_logs_share)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(onClick = {
|
||||||
|
geodeLogsViewModel.removeFile(geodeLog.filename)
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_delete),
|
||||||
|
contentDescription = stringResource(R.string.application_crashes_delete_one)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GeodeLogsListingScreen(
|
||||||
|
onBackPressedDispatcher: OnBackPressedDispatcher?,
|
||||||
|
geodeLogsViewModel: GeodeLogsViewModel = viewModel(factory = GeodeLogsViewModel.Factory)
|
||||||
|
) {
|
||||||
|
DirectoryListingScreen(
|
||||||
|
onBackPressedDispatcher,
|
||||||
|
geodeLogsViewModel,
|
||||||
|
titleId = R.string.title_activity_geode_logs,
|
||||||
|
noneAvailableId = R.string.application_geode_logs_no_logs,
|
||||||
|
) { line, shape ->
|
||||||
|
GeodeLogCard(
|
||||||
|
line,
|
||||||
|
geodeLogsViewModel,
|
||||||
|
modifier = Modifier.clip(shape)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,812 @@
|
|||||||
|
package com.geode.launcher.preferences
|
||||||
|
|
||||||
|
import android.app.UiModeManager
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
import android.view.WindowManager
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.OnBackPressedDispatcher
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.Spring
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.SnackbarResult
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalResources
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.geode.launcher.BuildConfig
|
||||||
|
import com.geode.launcher.R
|
||||||
|
import com.geode.launcher.UserDirectoryProvider
|
||||||
|
import com.geode.launcher.preferences.components.BackupButton
|
||||||
|
import com.geode.launcher.preferences.components.OptionsButton
|
||||||
|
import com.geode.launcher.preferences.components.OptionsCard
|
||||||
|
import com.geode.launcher.preferences.components.OptionsGroup
|
||||||
|
import com.geode.launcher.preferences.components.OptionsLabel
|
||||||
|
import com.geode.launcher.preferences.components.OptionsTitle
|
||||||
|
import com.geode.launcher.preferences.components.SafeModeDialog
|
||||||
|
import com.geode.launcher.preferences.components.SettingsCard
|
||||||
|
import com.geode.launcher.preferences.components.SettingsFPSCard
|
||||||
|
import com.geode.launcher.preferences.components.SettingsRangeCard
|
||||||
|
import com.geode.launcher.preferences.components.SettingsSelectCard
|
||||||
|
import com.geode.launcher.preferences.components.SettingsStringSelectCard
|
||||||
|
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||||
|
import com.geode.launcher.ui.theme.LocalTheme
|
||||||
|
import com.geode.launcher.ui.theme.Theme
|
||||||
|
import com.geode.launcher.ui.theme.Typography
|
||||||
|
import com.geode.launcher.ui.theme.robotoMonoFamily
|
||||||
|
import com.geode.launcher.updater.ReleaseViewModel
|
||||||
|
import com.geode.launcher.utils.ApplicationIcon
|
||||||
|
import com.geode.launcher.utils.Constants
|
||||||
|
import com.geode.launcher.utils.GamePackageUtils
|
||||||
|
import com.geode.launcher.utils.IconUtils
|
||||||
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.net.ConnectException
|
||||||
|
import java.net.UnknownHostException
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
|
||||||
|
val theme = Theme.fromInt(themeOption)
|
||||||
|
|
||||||
|
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
|
||||||
|
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalTheme provides theme) {
|
||||||
|
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
SettingsScreen(
|
||||||
|
onBackPressedDispatcher = onBackPressedDispatcher
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onOpenFileManager(context: Context) {
|
||||||
|
// future thoughts: Intent.ACTION_VIEW may not work on some devices
|
||||||
|
// the "correct" solution is to go through a list of possible intents/applications
|
||||||
|
// don't feel like writing that behavior. this is good enough
|
||||||
|
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||||
|
data = DocumentsContract.buildRootUri(
|
||||||
|
"${context.packageName}.user",
|
||||||
|
UserDirectoryProvider.ROOT
|
||||||
|
)
|
||||||
|
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||||
|
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
|
||||||
|
Intent.FLAG_GRANT_PREFIX_URI_PERMISSION or
|
||||||
|
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
context.startActivity(intent)
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
context.getText(R.string.no_activity_found), Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UpdateErrorDialog(
|
||||||
|
error: Exception,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val clipboard = LocalClipboard.current
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val clipboardLabel = stringResource(R.string.application_log_copy_label)
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_warning),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
title = { Text(stringResource(R.string.preference_check_for_updates_help_title)) },
|
||||||
|
text = {
|
||||||
|
SelectionContainer {
|
||||||
|
Text(
|
||||||
|
error.stackTraceToString(),
|
||||||
|
fontFamily = robotoMonoFamily,
|
||||||
|
style = Typography.bodyMedium,
|
||||||
|
modifier = Modifier.verticalScroll(rememberScrollState())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
clipboard.setClipEntry(
|
||||||
|
ClipEntry(ClipData.newPlainText(clipboardLabel, error.stackTraceToString()))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Text(stringResource(R.string.preference_check_for_updates_help_copy))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.message_box_accept))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismissRequest = onDismiss
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UpdateIndicator(
|
||||||
|
snackbarHostState: SnackbarHostState,
|
||||||
|
updateStatus: ReleaseViewModel.ReleaseUIState
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val resources = LocalResources.current
|
||||||
|
|
||||||
|
var enablePopup by remember { mutableStateOf(false) }
|
||||||
|
var enableHelp by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
var currentError by remember { mutableStateOf<Exception?>(null) }
|
||||||
|
|
||||||
|
val currentErrorS = currentError
|
||||||
|
if (currentErrorS != null && enableHelp) {
|
||||||
|
UpdateErrorDialog(currentErrorS) {
|
||||||
|
enableHelp = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
when (updateStatus) {
|
||||||
|
is ReleaseViewModel.ReleaseUIState.InUpdateCheck -> {
|
||||||
|
enablePopup = true
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
is ReleaseViewModel.ReleaseUIState.InDownload -> {
|
||||||
|
// is this the ideal design? idk
|
||||||
|
enablePopup = true
|
||||||
|
if (updateStatus.outOf != null) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
progress = {
|
||||||
|
val progress = updateStatus.downloaded / updateStatus.outOf.toDouble()
|
||||||
|
progress.toFloat()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(updateStatus) {
|
||||||
|
// only show popup if some progress was shown
|
||||||
|
if (!enablePopup) {
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
|
||||||
|
when (updateStatus) {
|
||||||
|
is ReleaseViewModel.ReleaseUIState.Failure -> {
|
||||||
|
val message = when (updateStatus.exception) {
|
||||||
|
is UnknownHostException, is ConnectException ->
|
||||||
|
resources.getString(R.string.release_fetch_no_internet)
|
||||||
|
else -> resources.getString(R.string.preference_check_for_updates_failed)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentError = updateStatus.exception
|
||||||
|
|
||||||
|
val snackbarResult = snackbarHostState.showSnackbar(message, resources.getString(R.string.preference_check_for_updates_failed_action))
|
||||||
|
if (snackbarResult == SnackbarResult.ActionPerformed) {
|
||||||
|
enableHelp = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ReleaseViewModel.ReleaseUIState.Finished -> {
|
||||||
|
if (updateStatus.hasUpdated) {
|
||||||
|
snackbarHostState.showSnackbar(
|
||||||
|
resources.getString(R.string.preference_check_for_updates_success)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val gameVersion = GamePackageUtils.getGameVersionCodeOrNull(context.packageManager)
|
||||||
|
if (gameVersion != null && gameVersion < Constants.SUPPORTED_VERSION_CODE) {
|
||||||
|
snackbarHostState.showSnackbar(
|
||||||
|
resources.getString(R.string.launcher_game_update_required_short)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
snackbarHostState.showSnackbar(
|
||||||
|
resources.getString(R.string.preference_check_for_updates_none_found)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateTheme(context: Context, theme: Int) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
|
||||||
|
uiModeManager.setApplicationNightMode(
|
||||||
|
when (theme) {
|
||||||
|
1 -> UiModeManager.MODE_NIGHT_NO
|
||||||
|
2 -> UiModeManager.MODE_NIGHT_YES
|
||||||
|
else -> UiModeManager.MODE_NIGHT_CUSTOM
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
AppCompatDelegate.setDefaultNightMode(
|
||||||
|
when (theme) {
|
||||||
|
1 -> AppCompatDelegate.MODE_NIGHT_NO
|
||||||
|
2 -> AppCompatDelegate.MODE_NIGHT_YES
|
||||||
|
else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onOpenDeveloperOptions(context: Context) {
|
||||||
|
val launchIntent = Intent(context, DeveloperSettingsActivity::class.java)
|
||||||
|
context.startActivity(launchIntent)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DeveloperModeDialog(onDismiss: () -> Unit, onEnable: () -> Unit) {
|
||||||
|
AlertDialog(
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_warning),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
title = { Text(stringResource(R.string.preference_developer_options_dialog_title)) },
|
||||||
|
text = { Text(stringResource(R.string.preference_developer_mode_about)) },
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.message_box_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onEnable) {
|
||||||
|
Text(stringResource(R.string.message_box_accept))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismissRequest = onDismiss
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GeneralSettingsGroup() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
OptionsGroup(stringResource(R.string.preference_category_testing)) {
|
||||||
|
SettingsSelectCard(
|
||||||
|
title = stringResource(R.string.preference_theme_name),
|
||||||
|
dialogTitle = stringResource(R.string.preference_theme_select),
|
||||||
|
preferenceKey = PreferenceUtils.Key.THEME,
|
||||||
|
options = linkedMapOf(
|
||||||
|
0 to stringResource(R.string.preference_theme_default),
|
||||||
|
1 to stringResource(R.string.preference_theme_light),
|
||||||
|
2 to stringResource(R.string.preference_theme_dark),
|
||||||
|
),
|
||||||
|
extraSelectBehavior = { updateTheme(context, it) }
|
||||||
|
)
|
||||||
|
SettingsCard(
|
||||||
|
title = stringResource(R.string.preference_black_background_name),
|
||||||
|
preferenceKey = PreferenceUtils.Key.BLACK_BACKGROUND
|
||||||
|
)
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
SettingsCard(
|
||||||
|
title = stringResource(R.string.preference_disable_styles),
|
||||||
|
preferenceKey = PreferenceUtils.Key.DISABLE_USER_THEME
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val currentIcon by PreferenceUtils.useStringPreference(PreferenceUtils.Key.SELECTED_ICON)
|
||||||
|
val iconName = remember(currentIcon) {
|
||||||
|
IconUtils.getIconDetails(
|
||||||
|
ApplicationIcon.fromId(currentIcon ?: "default")
|
||||||
|
).nameId
|
||||||
|
}
|
||||||
|
|
||||||
|
OptionsButton(
|
||||||
|
title = stringResource(R.string.preference_set_application_icon),
|
||||||
|
onClick = {
|
||||||
|
val launchIntent = Intent(context, ApplicationIconActivity::class.java)
|
||||||
|
context.startActivity(launchIntent)
|
||||||
|
},
|
||||||
|
description = stringResource(iconName),
|
||||||
|
displayInline = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
OptionsButton(
|
||||||
|
title = stringResource(R.string.preferences_open_file_manager),
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_folder),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { onOpenFileManager(context) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GameplaySettingsGroup() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
OptionsGroup(stringResource(R.string.preference_category_gameplay)) {
|
||||||
|
SettingsCard(
|
||||||
|
title = stringResource(R.string.preference_load_automatically_name),
|
||||||
|
description = stringResource(R.string.preference_load_automatically_description),
|
||||||
|
preferenceKey = PreferenceUtils.Key.LOAD_AUTOMATICALLY,
|
||||||
|
)
|
||||||
|
SettingsSelectCard(
|
||||||
|
title = stringResource(R.string.preference_display_mode_name),
|
||||||
|
dialogTitle = stringResource(R.string.preference_display_mode_select),
|
||||||
|
preferenceKey = PreferenceUtils.Key.DISPLAY_MODE,
|
||||||
|
options = buildMap {
|
||||||
|
put(0, stringResource(R.string.preference_display_mode_default))
|
||||||
|
put(1, stringResource(R.string.preference_display_mode_legacy))
|
||||||
|
|
||||||
|
// necessary api doesn't exist on older versions of android
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
put(2, stringResource(R.string.preference_display_mode_fullscreen))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
val currentDisplayMode by PreferenceUtils.useIntPreference(PreferenceUtils.Key.DISPLAY_MODE)
|
||||||
|
|
||||||
|
if (currentDisplayMode == 1) {
|
||||||
|
SettingsStringSelectCard(
|
||||||
|
title = stringResource(R.string.preference_custom_aspect_ratio_name),
|
||||||
|
description = stringResource(R.string.preference_custom_aspect_ratio_description),
|
||||||
|
dialogTitle = stringResource(R.string.preference_custom_aspect_ratio_select),
|
||||||
|
preferenceKey = PreferenceUtils.Key.CUSTOM_ASPECT_RATIO,
|
||||||
|
options = linkedMapOf(
|
||||||
|
"16_9" to stringResource(R.string.preference_custom_aspect_ratio_16x9),
|
||||||
|
"16_10" to stringResource(R.string.preference_custom_aspect_ratio_16x10),
|
||||||
|
"4_3" to stringResource(R.string.preference_custom_aspect_ratio_4x3),
|
||||||
|
"1_1" to stringResource(R.string.preference_custom_aspect_ratio_1x1),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsRangeCard(
|
||||||
|
title = stringResource(R.string.preference_screen_zoom_name),
|
||||||
|
description = stringResource(R.string.preference_screen_zoom_description),
|
||||||
|
dialogTitle = stringResource(R.string.preference_screen_zoom_select),
|
||||||
|
preferenceKey = PreferenceUtils.Key.SCREEN_ZOOM,
|
||||||
|
labelSuffix = "x",
|
||||||
|
range = 25..100,
|
||||||
|
scale = 100,
|
||||||
|
step = 5,
|
||||||
|
) {
|
||||||
|
SettingsCard(
|
||||||
|
title = stringResource(R.string.preference_screen_zoom_fit_name),
|
||||||
|
preferenceKey = PreferenceUtils.Key.SCREEN_ZOOM_FIT,
|
||||||
|
asCard = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val maxFrameRate = remember {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
context.display.supportedModes.maxOf { it.refreshRate }
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
(context.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay.refreshRate
|
||||||
|
}.roundToInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsFPSCard(
|
||||||
|
title = stringResource(R.string.preference_limit_framerate_name),
|
||||||
|
dialogTitle = stringResource(R.string.preference_limit_framerate_select),
|
||||||
|
preferenceKey = PreferenceUtils.Key.LIMIT_FRAME_RATE,
|
||||||
|
maxFrameRate = maxFrameRate
|
||||||
|
)
|
||||||
|
|
||||||
|
var showSafeModeDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
OptionsButton(
|
||||||
|
title = stringResource(R.string.preference_launch_safe_mode),
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_shield),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
showSafeModeDialog = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showSafeModeDialog) {
|
||||||
|
SafeModeDialog(onDismiss = {
|
||||||
|
showSafeModeDialog = false
|
||||||
|
}) {
|
||||||
|
val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)!!.apply {
|
||||||
|
// hardcode it, why not
|
||||||
|
action = Intent.ACTION_VIEW
|
||||||
|
data = "geode-launcher://main/launch?safe-mode=true".toUri()
|
||||||
|
}
|
||||||
|
context.startActivity(launchIntent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UpdateSettingsGroup(releaseViewModel: ReleaseViewModel, snackbarHostState: SnackbarHostState, onUpdate: () -> Unit) {
|
||||||
|
if (BuildConfig.GOOGLE_PLAY_BUILD) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val updateStatus by releaseViewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
OptionsGroup(stringResource(R.string.preference_category_updater)) {
|
||||||
|
SettingsCard(
|
||||||
|
title = stringResource(R.string.preference_update_automatically_name),
|
||||||
|
preferenceKey = PreferenceUtils.Key.UPDATE_AUTOMATICALLY,
|
||||||
|
)
|
||||||
|
|
||||||
|
val lastUpdateCheck by PreferenceUtils.useLongPreference(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME)
|
||||||
|
val checkAgoString = remember(lastUpdateCheck) {
|
||||||
|
if (lastUpdateCheck != 0L) {
|
||||||
|
val now = Clock.System.now().toEpochMilliseconds()
|
||||||
|
DateUtils.getRelativeTimeSpanString(
|
||||||
|
lastUpdateCheck,
|
||||||
|
now,
|
||||||
|
DateUtils.MINUTE_IN_MILLIS,
|
||||||
|
DateUtils.FORMAT_ABBREV_RELATIVE
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
"never"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OptionsCard(
|
||||||
|
title = {
|
||||||
|
OptionsTitle(
|
||||||
|
title = stringResource(R.string.preference_check_for_updates_button),
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_update),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
description = stringResource(
|
||||||
|
R.string.preference_check_for_updates_description,
|
||||||
|
checkAgoString
|
||||||
|
)
|
||||||
|
.takeIf { lastUpdateCheck != 0L }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable(
|
||||||
|
onClick = {
|
||||||
|
onUpdate()
|
||||||
|
},
|
||||||
|
role = Role.Button
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
UpdateIndicator(snackbarHostState, updateStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AdvancedSettingsGroup(developerModeEnabled: Boolean, snackbarHostState: SnackbarHostState) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
OptionsGroup(stringResource(R.string.preference_category_developer)) {
|
||||||
|
if (developerModeEnabled) {
|
||||||
|
OptionsButton(
|
||||||
|
title = stringResource(R.string.preference_open_developer_options),
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_data_object),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { onOpenDeveloperOptions(context) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
OptionsButton(
|
||||||
|
title = stringResource(R.string.preferences_view_geode_logs),
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_description),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
val launchIntent = Intent(context, GeodeLogsListingActivity::class.java)
|
||||||
|
context.startActivity(launchIntent)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
OptionsButton(
|
||||||
|
title = stringResource(R.string.preferences_view_crashes),
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_bug_report),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
val launchIntent = Intent(context, CrashLogsActivity::class.java)
|
||||||
|
context.startActivity(launchIntent)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
BackupButton(snackbarHostState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AboutSettingsGroup(developerModeEnabled: Boolean) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val resources = LocalResources.current
|
||||||
|
val currentRelease by PreferenceUtils.useStringPreference(PreferenceUtils.Key.CURRENT_VERSION_TAG)
|
||||||
|
|
||||||
|
OptionsGroup(stringResource(R.string.preference_category_about)) {
|
||||||
|
var launcherButtonTapped by remember { mutableIntStateOf(0) }
|
||||||
|
var showDeveloperDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
OptionsButton(
|
||||||
|
stringResource(R.string.preference_launcher_version_name),
|
||||||
|
stringResource(
|
||||||
|
R.string.preference_launcher_version_description,
|
||||||
|
BuildConfig.VERSION_NAME
|
||||||
|
),
|
||||||
|
displayInline = true
|
||||||
|
) {
|
||||||
|
launcherButtonTapped += 1
|
||||||
|
|
||||||
|
if (launcherButtonTapped >= 7) {
|
||||||
|
if (developerModeEnabled) {
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
R.string.preference_enable_developer_again,
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
)
|
||||||
|
.show()
|
||||||
|
} else {
|
||||||
|
showDeveloperDialog = true
|
||||||
|
}
|
||||||
|
launcherButtonTapped = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDeveloperDialog) {
|
||||||
|
DeveloperModeDialog(
|
||||||
|
onDismiss = {
|
||||||
|
showDeveloperDialog = false
|
||||||
|
},
|
||||||
|
onEnable = {
|
||||||
|
PreferenceUtils.get(context)
|
||||||
|
.setBoolean(PreferenceUtils.Key.DEVELOPER_MODE, true)
|
||||||
|
showDeveloperDialog = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("SENSELESS_COMPARISON")
|
||||||
|
OptionsLabel(
|
||||||
|
stringResource(R.string.preference_loader_version_name),
|
||||||
|
stringResource(
|
||||||
|
R.string.preference_loader_version_description,
|
||||||
|
if (BuildConfig.PREBUNDLED_GEODE != null)
|
||||||
|
"${BuildConfig.PREBUNDLED_GEODE}*"
|
||||||
|
else (currentRelease ?: "unknown")
|
||||||
|
),
|
||||||
|
displayInline = true
|
||||||
|
)
|
||||||
|
|
||||||
|
val gameInstalled = remember {
|
||||||
|
GamePackageUtils.isGameInstalled(context.packageManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gameInstalled) {
|
||||||
|
val gameVersion = remember {
|
||||||
|
GamePackageUtils.getUnifiedVersionName(context.packageManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
val gameSource = remember {
|
||||||
|
resources.getString(
|
||||||
|
when (GamePackageUtils.identifyGameSource(context.packageManager)) {
|
||||||
|
GamePackageUtils.GameSource.GOOGLE -> R.string.preference_game_source_google
|
||||||
|
GamePackageUtils.GameSource.AMAZON -> R.string.preference_game_source_amazon
|
||||||
|
else -> R.string.preference_game_source_unknown
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
OptionsLabel(
|
||||||
|
stringResource(R.string.preference_game_version_name),
|
||||||
|
stringResource(
|
||||||
|
R.string.preference_game_version_description,
|
||||||
|
gameVersion,
|
||||||
|
gameSource
|
||||||
|
),
|
||||||
|
displayInline = true
|
||||||
|
)
|
||||||
|
|
||||||
|
OptionsLabel(
|
||||||
|
stringResource(R.string.preference_architecture_name),
|
||||||
|
stringResource(
|
||||||
|
R.string.preference_architecture_description,
|
||||||
|
Build.MODEL,
|
||||||
|
LaunchUtils.applicationArchitecture
|
||||||
|
),
|
||||||
|
displayInline = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(
|
||||||
|
onBackPressedDispatcher: OnBackPressedDispatcher?,
|
||||||
|
releaseViewModel: ReleaseViewModel = viewModel(factory = ReleaseViewModel.Factory)
|
||||||
|
) {
|
||||||
|
val resources = LocalResources.current
|
||||||
|
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
var showUpdateInProgress by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
if (showUpdateInProgress) {
|
||||||
|
LaunchedEffect(snackbarHostState) {
|
||||||
|
snackbarHostState.showSnackbar(
|
||||||
|
resources.getString(R.string.preference_check_for_updates_already_updating)
|
||||||
|
)
|
||||||
|
showUpdateInProgress = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fix theme transition by giving it the exact same animation as the top bar
|
||||||
|
val containerColor by animateColorAsState(
|
||||||
|
targetValue = MaterialTheme.colorScheme.background,
|
||||||
|
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
|
||||||
|
label = "background color"
|
||||||
|
)
|
||||||
|
|
||||||
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
|
containerColor = containerColor,
|
||||||
|
snackbarHost = {
|
||||||
|
SnackbarHost(hostState = snackbarHostState)
|
||||||
|
},
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_arrow_back),
|
||||||
|
contentDescription = stringResource(R.string.back_icon_alt)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.title_activity_settings),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
},
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(MaterialTheme.colorScheme.background),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
content = { innerPadding ->
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
GeneralSettingsGroup()
|
||||||
|
GameplaySettingsGroup()
|
||||||
|
|
||||||
|
UpdateSettingsGroup(releaseViewModel, snackbarHostState, onUpdate = {
|
||||||
|
if (releaseViewModel.isInUpdate) {
|
||||||
|
showUpdateInProgress = true
|
||||||
|
} else {
|
||||||
|
releaseViewModel.runReleaseCheck(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
val developerModeEnabled by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DEVELOPER_MODE)
|
||||||
|
|
||||||
|
AdvancedSettingsGroup(developerModeEnabled, snackbarHostState)
|
||||||
|
AboutSettingsGroup(developerModeEnabled)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showSystemUi = true)
|
||||||
|
@Composable
|
||||||
|
fun DefaultPreview() {
|
||||||
|
GeodeLauncherTheme {
|
||||||
|
SettingsScreen(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package com.geode.launcher.preferences
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.OnBackPressedDispatcher
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.geode.launcher.R
|
||||||
|
import com.geode.launcher.UserDirectoryProvider
|
||||||
|
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||||
|
import com.geode.launcher.ui.theme.LocalTheme
|
||||||
|
import com.geode.launcher.ui.theme.Theme
|
||||||
|
import com.geode.launcher.ui.theme.Typography
|
||||||
|
import com.geode.launcher.ui.theme.robotoMonoFamily
|
||||||
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
|
||||||
|
class TextViewActivity : ComponentActivity() {
|
||||||
|
companion object {
|
||||||
|
const val EXTRA_LOG_VIEW_FILENAME = "EXTRA_LOG_FILENAME"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
val filename = intent.getStringExtra(EXTRA_LOG_VIEW_FILENAME)
|
||||||
|
if (filename.isNullOrEmpty()) {
|
||||||
|
val launchIntent = Intent(this, GeodeLogsListingActivity::class.java)
|
||||||
|
startActivity(launchIntent)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
|
||||||
|
val theme = Theme.fromInt(themeOption)
|
||||||
|
|
||||||
|
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
|
||||||
|
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalTheme provides theme) {
|
||||||
|
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
GeodeLogView(onBackPressedDispatcher, filename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onShareFile(context: Context, filename: File) {
|
||||||
|
val baseDirectory = LaunchUtils.getBaseDirectory(context, true)
|
||||||
|
|
||||||
|
val providerLocation = filename.toRelativeString(baseDirectory)
|
||||||
|
val documentsPath = "${UserDirectoryProvider.ROOT}${providerLocation}"
|
||||||
|
|
||||||
|
val uri = DocumentsContract.buildDocumentUri("${context.packageName}.user", documentsPath)
|
||||||
|
|
||||||
|
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||||
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
|
type = "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
context.startActivity(shareIntent)
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
Toast.makeText(context, R.string.no_activity_found, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun GeodeLogView(
|
||||||
|
onBackPressedDispatcher: OnBackPressedDispatcher?,
|
||||||
|
filename: String,
|
||||||
|
) {
|
||||||
|
var loadingFile by remember { mutableStateOf(true) }
|
||||||
|
var description by remember { mutableStateOf(emptyList<String>()) }
|
||||||
|
|
||||||
|
val toRead = File(filename)
|
||||||
|
|
||||||
|
LaunchedEffect(filename) {
|
||||||
|
loadingFile = true
|
||||||
|
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
if (!toRead.isFile) {
|
||||||
|
description = listOf("File does not exist!")
|
||||||
|
} else {
|
||||||
|
description = toRead.readLines()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingFile = false
|
||||||
|
}
|
||||||
|
|
||||||
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
|
topBar = {
|
||||||
|
Column {
|
||||||
|
TopAppBar(
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_arrow_back),
|
||||||
|
contentDescription = stringResource(R.string.back_icon_alt)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
toRead.name,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
},
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
actions = {
|
||||||
|
val context = LocalContext.current
|
||||||
|
IconButton(onClick = { onShareFile(context, toRead) }) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_share),
|
||||||
|
contentDescription = stringResource(R.string.application_logs_share)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (loadingFile) {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
if (!loadingFile) {
|
||||||
|
if (description.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.application_geode_logs_empty),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
style = Typography.bodyLarge,
|
||||||
|
modifier = Modifier.fillMaxSize().padding(8.dp)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
SelectionContainer {
|
||||||
|
LazyColumn(modifier = Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.padding(horizontal = 8.dp)
|
||||||
|
) {
|
||||||
|
|
||||||
|
items(description) { line ->
|
||||||
|
Text(
|
||||||
|
line,
|
||||||
|
fontFamily = robotoMonoFamily,
|
||||||
|
style = Typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package com.geode.launcher.preferences.components
|
||||||
|
|
||||||
|
import androidx.activity.OnBackPressedDispatcher
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.geode.launcher.R
|
||||||
|
import com.geode.launcher.log.BaseDirectoryViewModel
|
||||||
|
import com.geode.launcher.ui.theme.Typography
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CrashLogsTopBar(
|
||||||
|
onBackPressedDispatcher: OnBackPressedDispatcher?,
|
||||||
|
directoryViewModel: BaseDirectoryViewModel<*>,
|
||||||
|
scrollBehavior: TopAppBarScrollBehavior,
|
||||||
|
@StringRes titleId: Int,
|
||||||
|
additionalOptions: (@Composable (() -> Unit) -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val isLoading by directoryViewModel.isLoading.collectAsState()
|
||||||
|
var showMoreOptions by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Column {
|
||||||
|
TopAppBar(
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_arrow_back),
|
||||||
|
contentDescription = stringResource(R.string.back_icon_alt)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { showMoreOptions = !showMoreOptions }) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_more_vert),
|
||||||
|
contentDescription = stringResource(R.string.application_logs_more)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showMoreOptions,
|
||||||
|
onDismissRequest = { showMoreOptions = false }
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_delete),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(stringResource(R.string.application_logs_delete))
|
||||||
|
}, onClick = {
|
||||||
|
directoryViewModel.clearAllFiles()
|
||||||
|
showMoreOptions = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
additionalOptions?.invoke {
|
||||||
|
showMoreOptions = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
stringResource(titleId),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
},
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun <T> CrashLogsListing(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
directoryViewModel: BaseDirectoryViewModel<T>,
|
||||||
|
@StringRes noneAvailableId: Int,
|
||||||
|
logCard: @Composable (T, Shape) -> Unit,
|
||||||
|
) {
|
||||||
|
val logLines = directoryViewModel.lineState
|
||||||
|
val isLoading by directoryViewModel.isLoading.collectAsState()
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier,
|
||||||
|
verticalArrangement = Arrangement.SpaceEvenly,
|
||||||
|
) {
|
||||||
|
if (!isLoading && logLines.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
stringResource(noneAvailableId),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
style = Typography.bodyLarge,
|
||||||
|
modifier = Modifier.fillMaxSize().padding(8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val itemsSize = logLines.size
|
||||||
|
|
||||||
|
itemsIndexed(
|
||||||
|
logLines,
|
||||||
|
key = { index, line -> directoryViewModel.getItemKey(line) }
|
||||||
|
) { index, line ->
|
||||||
|
val shape = RoundedCornerShape(
|
||||||
|
topStart = if (index == 0) 16.dp else 4.dp,
|
||||||
|
topEnd = if (index == 0) 16.dp else 4.dp,
|
||||||
|
bottomStart = if (index + 1 == itemsSize) 16.dp else 4.dp,
|
||||||
|
bottomEnd = if (index + 1 == itemsSize) 16.dp else 4.dp
|
||||||
|
)
|
||||||
|
|
||||||
|
logCard(line, shape)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun <T> DirectoryListingScreen(
|
||||||
|
onBackPressedDispatcher: OnBackPressedDispatcher?,
|
||||||
|
directoryViewModel: BaseDirectoryViewModel<T>,
|
||||||
|
@StringRes titleId: Int,
|
||||||
|
@StringRes noneAvailableId: Int,
|
||||||
|
additionalOptions: (@Composable (() -> Unit) -> Unit)? = null,
|
||||||
|
logCard: @Composable (T, Shape) -> Unit,
|
||||||
|
) {
|
||||||
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
|
topBar = {
|
||||||
|
CrashLogsTopBar(
|
||||||
|
onBackPressedDispatcher,
|
||||||
|
directoryViewModel,
|
||||||
|
scrollBehavior,
|
||||||
|
titleId,
|
||||||
|
additionalOptions = additionalOptions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
CrashLogsListing(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
directoryViewModel,
|
||||||
|
noneAvailableId = noneAvailableId,
|
||||||
|
logCard = logCard
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
package com.geode.launcher.preferences.components
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import android.util.Xml
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import com.geode.launcher.R
|
||||||
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.FormBody
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.coroutines.executeAsync
|
||||||
|
import okio.Buffer
|
||||||
|
import okio.ByteString.Companion.decodeBase64
|
||||||
|
import okio.GzipSource
|
||||||
|
import okio.buffer
|
||||||
|
import okio.source
|
||||||
|
import org.xmlpull.v1.XmlPullParser
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.experimental.xor
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
const val TAG = "com.geode.launcher.SaveBackup"
|
||||||
|
|
||||||
|
data class UserDetails(
|
||||||
|
val userName: String,
|
||||||
|
val gjp2: String,
|
||||||
|
val accountId: Int,
|
||||||
|
val uuid: Int,
|
||||||
|
val udid: String,
|
||||||
|
val binaryVersion: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class BackupParseFailure {
|
||||||
|
SAVE_MISSING,
|
||||||
|
DECODE_FAILED,
|
||||||
|
DETAILS_MISSING,
|
||||||
|
SAVE_TOO_LARGE
|
||||||
|
}
|
||||||
|
|
||||||
|
class BackupParseException(val failureCode: BackupParseFailure) : Exception("parse error: $failureCode")
|
||||||
|
|
||||||
|
enum class BackupUploadFailure {
|
||||||
|
GENERIC_ERROR,
|
||||||
|
NETWORK_ERROR,
|
||||||
|
INVALID_LOGIN
|
||||||
|
}
|
||||||
|
|
||||||
|
class BackupUploadException(val failureCode: BackupUploadFailure, val gjCode: Int = -1) : Exception("upload error: $failureCode")
|
||||||
|
|
||||||
|
suspend fun getAccountBackupUrl(backupClient: OkHttpClient, details: UserDetails): Result<HttpUrl> {
|
||||||
|
return try {
|
||||||
|
// accountID=%i&type=1&secret=Wmfd2893gb7
|
||||||
|
val body = FormBody.Builder()
|
||||||
|
.add("secret", "Wmfd2893gb7")
|
||||||
|
.add("type", "1")
|
||||||
|
.add("accountID", details.accountId.toString())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("https://www.boomlings.com/database/getAccountURL.php")
|
||||||
|
.addHeader("User-Agent", "")
|
||||||
|
.post(body)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val call = backupClient.newCall(request)
|
||||||
|
call.executeAsync()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "getAccountBackupUrl failed: ${e.stackTraceToString()}")
|
||||||
|
return Result.failure(BackupUploadException(BackupUploadFailure.NETWORK_ERROR))
|
||||||
|
}.use { response ->
|
||||||
|
if (response.code != 200) {
|
||||||
|
return Result.failure(BackupUploadException(BackupUploadFailure.GENERIC_ERROR))
|
||||||
|
}
|
||||||
|
|
||||||
|
val url = response.body.string().toHttpUrlOrNull()
|
||||||
|
?: return Result.failure(BackupUploadException(BackupUploadFailure.GENERIC_ERROR))
|
||||||
|
|
||||||
|
Result.success(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decryptFileToBuffer(file: File, buffer: Buffer) = file.source().buffer()
|
||||||
|
.use { baseBuffer ->
|
||||||
|
val key = 0xb.toByte()
|
||||||
|
val nullByte = 0x0.toByte()
|
||||||
|
|
||||||
|
while (!baseBuffer.exhausted()) {
|
||||||
|
val b = baseBuffer.readByte()
|
||||||
|
val d = b.xor(key)
|
||||||
|
|
||||||
|
// gd sometimes encodes garbage after null byte, treat it like a c string
|
||||||
|
if (d == nullByte)
|
||||||
|
break
|
||||||
|
|
||||||
|
buffer.writeByte(d.toInt())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun generateSaveString(gameManager: File, localLevels: File): String = Buffer()
|
||||||
|
.use { saveBuffer ->
|
||||||
|
decryptFileToBuffer(gameManager, saveBuffer)
|
||||||
|
saveBuffer.writeByte(';'.code)
|
||||||
|
decryptFileToBuffer(localLevels, saveBuffer)
|
||||||
|
|
||||||
|
saveBuffer.readUtf8()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun generateBackupRequest(details: UserDetails, url: HttpUrl, gameManager: File, localLevels: File): Request {
|
||||||
|
val saveData = generateSaveString(gameManager, localLevels)
|
||||||
|
|
||||||
|
// %s/database/accounts/backupGJAccountNew.php
|
||||||
|
val backupUrl = url.newBuilder()
|
||||||
|
.addPathSegments("database/accounts/backupGJAccountNew.php")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// gameVersion=22&binaryVersion=%i&dvs=2&udid=%s&uuid=%i&accountID=%i&gjp2=%s&secret=Wmfv3899gc9&saveData=%s
|
||||||
|
val body = FormBody.Builder()
|
||||||
|
.add("gameVersion", "22")
|
||||||
|
.add("binaryVersion", details.binaryVersion.toString())
|
||||||
|
.add("dvs", "2")
|
||||||
|
.add("udid", details.udid)
|
||||||
|
.add("uuid", details.uuid.toString())
|
||||||
|
.add("accountID", details.accountId.toString())
|
||||||
|
.add("secret", "Wmfv3899gc9")
|
||||||
|
.add("gjp2", details.gjp2)
|
||||||
|
.add("saveData", saveData)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return Request.Builder()
|
||||||
|
.url(backupUrl)
|
||||||
|
.addHeader("User-Agent", "")
|
||||||
|
.post(body)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun performBackupRequest(backupClient: OkHttpClient, details: UserDetails, url: HttpUrl, gameManager: File, localLevels: File): Result<Unit> {
|
||||||
|
return try {
|
||||||
|
// we split this into multiple functions to free up as many references as we can
|
||||||
|
// go little gc go
|
||||||
|
backupClient.newCall(
|
||||||
|
generateBackupRequest(details, url, gameManager, localLevels)
|
||||||
|
).executeAsync()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "performBackupRequest failed: ${e.stackTraceToString()}")
|
||||||
|
return Result.failure(BackupUploadException(BackupUploadFailure.GENERIC_ERROR))
|
||||||
|
}.use { response ->
|
||||||
|
if (response.code != 200) {
|
||||||
|
return Result.failure(BackupUploadException(BackupUploadFailure.GENERIC_ERROR))
|
||||||
|
}
|
||||||
|
|
||||||
|
val responseBody = response.body.string().toIntOrNull()
|
||||||
|
?: return Result.failure(BackupUploadException(BackupUploadFailure.GENERIC_ERROR))
|
||||||
|
|
||||||
|
when (responseBody) {
|
||||||
|
1 -> Result.success(Unit)
|
||||||
|
-5 -> Result.failure(BackupUploadException(BackupUploadFailure.INVALID_LOGIN, responseBody))
|
||||||
|
else -> Result.failure(BackupUploadException(BackupUploadFailure.GENERIC_ERROR, responseBody))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun skip(parser: XmlPullParser) {
|
||||||
|
if (parser.eventType != XmlPullParser.START_TAG) {
|
||||||
|
throw IllegalStateException()
|
||||||
|
}
|
||||||
|
var depth = 1
|
||||||
|
while (depth != 0) {
|
||||||
|
when (parser.next()) {
|
||||||
|
XmlPullParser.END_TAG -> depth--
|
||||||
|
XmlPullParser.START_TAG -> depth++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getNextValue(parser: XmlPullParser): String? {
|
||||||
|
while (parser.eventType != XmlPullParser.START_TAG && parser.eventType != XmlPullParser.END_DOCUMENT) {
|
||||||
|
parser.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parser.eventType == XmlPullParser.START_TAG) {
|
||||||
|
// we could support t/f here but we also don't have to
|
||||||
|
if (parser.name != "s" && parser.name != "i") {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return parser.nextText()
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseSaveFile(parser: XmlPullParser): Result<UserDetails> = try {
|
||||||
|
var userName: String? = null
|
||||||
|
var gjp2: String? = null
|
||||||
|
var accountId: Int? = null
|
||||||
|
var uuid: Int? = null
|
||||||
|
var udid: String? = null
|
||||||
|
var binaryVersion: Int? = null
|
||||||
|
|
||||||
|
var inPlist = false
|
||||||
|
var inDict = false
|
||||||
|
|
||||||
|
var eventType = parser.eventType
|
||||||
|
while (eventType != XmlPullParser.END_DOCUMENT) {
|
||||||
|
when (eventType) {
|
||||||
|
XmlPullParser.START_TAG -> {
|
||||||
|
if (inDict) {
|
||||||
|
// ignore inner elements, just in case they conflict
|
||||||
|
if (parser.name != "k") {
|
||||||
|
skip(parser)
|
||||||
|
} else {
|
||||||
|
val keyName = parser.nextText()
|
||||||
|
when (keyName) {
|
||||||
|
"GJA_001" -> userName = getNextValue(parser)
|
||||||
|
"GJA_003" -> accountId = getNextValue(parser)?.toIntOrNull()
|
||||||
|
"GJA_005" -> gjp2 = getNextValue(parser)
|
||||||
|
"playerUserID" -> uuid = getNextValue(parser)?.toIntOrNull()
|
||||||
|
"playerUDID" -> udid = getNextValue(parser)
|
||||||
|
"binaryVersion" -> binaryVersion = getNextValue(parser)?.toIntOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// traverse to initial plist -> dict element
|
||||||
|
if (!inPlist && parser.name == "plist") {
|
||||||
|
inPlist = true
|
||||||
|
}
|
||||||
|
if (!inDict && parser.name == "dict") {
|
||||||
|
inDict = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eventType = parser.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userName == null || gjp2 == null || accountId == null || uuid == null || udid == null || binaryVersion == null) {
|
||||||
|
Result.failure(BackupParseException(BackupParseFailure.DETAILS_MISSING))
|
||||||
|
} else {
|
||||||
|
Result.success(
|
||||||
|
UserDetails(
|
||||||
|
userName = userName,
|
||||||
|
gjp2 = gjp2,
|
||||||
|
accountId = accountId,
|
||||||
|
uuid = uuid,
|
||||||
|
udid = udid,
|
||||||
|
binaryVersion = binaryVersion
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "parseSaveFile failed: ${e.stackTraceToString()}")
|
||||||
|
Result.failure(BackupParseException(BackupParseFailure.DECODE_FAILED))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decryptFileToString(file: File) = Buffer()
|
||||||
|
.use { decryptedBuffer ->
|
||||||
|
decryptFileToBuffer(file, decryptedBuffer)
|
||||||
|
decryptedBuffer.readUtf8()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getUserDetails(gameManager: File): Result<UserDetails> {
|
||||||
|
return try {
|
||||||
|
val base64Decoded = run {
|
||||||
|
// free decryptString after decoding
|
||||||
|
val decryptString = decryptFileToString(gameManager)
|
||||||
|
decryptString.decodeBase64()
|
||||||
|
} ?: return Result.failure(BackupParseException(BackupParseFailure.DECODE_FAILED))
|
||||||
|
|
||||||
|
// gzipSource will close the underlying buffer when closed
|
||||||
|
GzipSource(
|
||||||
|
Buffer().write(base64Decoded)
|
||||||
|
).buffer().use { gzipSource ->
|
||||||
|
runInterruptible {
|
||||||
|
val parser = Xml.newPullParser()
|
||||||
|
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
|
||||||
|
parser.setInput(gzipSource.inputStream(), null)
|
||||||
|
|
||||||
|
parseSaveFile(parser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "getUserDetails failed: ${e.stackTraceToString()}")
|
||||||
|
return Result.failure(BackupParseException(BackupParseFailure.DECODE_FAILED))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const val MB_TO_BYTES = 1048576 // 2^20
|
||||||
|
|
||||||
|
suspend fun backupSaveToCloud(context: Context): Result<Unit> {
|
||||||
|
val baseDirectory = LaunchUtils.getBaseDirectory(context)
|
||||||
|
|
||||||
|
val gameManager = File(baseDirectory,"/save/CCGameManager.dat")
|
||||||
|
val localLevels = File(baseDirectory, "/save/CCLocalLevels.dat")
|
||||||
|
|
||||||
|
if (!gameManager.isFile || !localLevels.isFile) {
|
||||||
|
return Result.failure(BackupParseException(BackupParseFailure.SAVE_MISSING))
|
||||||
|
}
|
||||||
|
|
||||||
|
// hardcode 32mb max size
|
||||||
|
// this is how the game checks it, which probably isn't the best
|
||||||
|
val totalSize = (gameManager.length() + localLevels.length()) / MB_TO_BYTES.toDouble()
|
||||||
|
println("Backup - calculated size of %.2fmb".format(totalSize))
|
||||||
|
|
||||||
|
if (totalSize > 32.0) {
|
||||||
|
return Result.failure(BackupParseException(BackupParseFailure.SAVE_TOO_LARGE))
|
||||||
|
}
|
||||||
|
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
val userDetails = getUserDetails(gameManager).getOrElse {
|
||||||
|
return@withContext Result.failure(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
println("Backup - performing as ${userDetails.userName}, accountID=${userDetails.accountId}")
|
||||||
|
|
||||||
|
val backupClient = OkHttpClient.Builder()
|
||||||
|
.writeTimeout(500.seconds)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val backupUrl = getAccountBackupUrl(backupClient, userDetails).getOrElse {
|
||||||
|
return@withContext Result.failure(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
performBackupRequest(backupClient, userDetails, backupUrl, gameManager, localLevels).getOrElse {
|
||||||
|
return@withContext Result.failure(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
println("Backup completed!")
|
||||||
|
|
||||||
|
Result.success(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BackupConfirmDialog(onDismiss: () -> Unit, onEnable: () -> Unit) {
|
||||||
|
AlertDialog(
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_cloud_alert),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
title = { Text(stringResource(R.string.preference_backup_confirm_title)) },
|
||||||
|
text = { Text(stringResource(R.string.preference_backup_confirm_description)) },
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.message_box_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onEnable) {
|
||||||
|
Text(stringResource(R.string.message_box_continue))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismissRequest = onDismiss
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun messageFromResult(context: Context, result: Result<Unit>): String {
|
||||||
|
val exception = result.exceptionOrNull()
|
||||||
|
?: return context.getString(R.string.preference_backup_success)
|
||||||
|
|
||||||
|
return when (exception) {
|
||||||
|
is BackupParseException -> when (exception.failureCode) {
|
||||||
|
BackupParseFailure.SAVE_MISSING -> context.getString(R.string.preference_backup_save_missing)
|
||||||
|
BackupParseFailure.DECODE_FAILED -> context.getString(R.string.preference_backup_save_decode_failed)
|
||||||
|
BackupParseFailure.DETAILS_MISSING -> context.getString(R.string.preference_backup_save_details_missing)
|
||||||
|
BackupParseFailure.SAVE_TOO_LARGE -> context.getString(R.string.preference_backup_save_too_large)
|
||||||
|
}
|
||||||
|
is BackupUploadException -> when (exception.failureCode) {
|
||||||
|
BackupUploadFailure.GENERIC_ERROR -> context.getString(R.string.preference_backup_failed, exception.gjCode)
|
||||||
|
BackupUploadFailure.NETWORK_ERROR -> context.getString(R.string.preference_backup_failed_network)
|
||||||
|
BackupUploadFailure.INVALID_LOGIN -> context.getString(R.string.preference_backup_failed_invalid_login)
|
||||||
|
}
|
||||||
|
else -> context.getString(R.string.preference_backup_failed_generic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BackupDialog(onDismiss: () -> Unit, isCancelling: Boolean) {
|
||||||
|
AlertDialog(
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_cloud_upload),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
if (isCancelling) {
|
||||||
|
Text(stringResource(R.string.preference_backup_cancelling))
|
||||||
|
} else {
|
||||||
|
Text(stringResource(R.string.preference_backup_in_progress))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
LinearProgressIndicator()
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss, enabled = !isCancelling) {
|
||||||
|
Text(stringResource(R.string.message_box_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismissRequest = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BackupButton(snackbarHostState: SnackbarHostState) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
var showBackupDialog by remember { mutableStateOf(false) }
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
var backupInProgress by remember { mutableStateOf(false) }
|
||||||
|
var backupJob by remember { mutableStateOf<Job?>(null) }
|
||||||
|
|
||||||
|
OptionsButton(
|
||||||
|
title = stringResource(R.string.preference_backup_cloud),
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_cloud_upload),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
showBackupDialog = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showBackupDialog) {
|
||||||
|
BackupConfirmDialog(onDismiss = {
|
||||||
|
showBackupDialog = false
|
||||||
|
}) {
|
||||||
|
showBackupDialog = false
|
||||||
|
|
||||||
|
backupJob = coroutineScope.launch {
|
||||||
|
backupInProgress = true
|
||||||
|
val responseStatus = backupSaveToCloud(context)
|
||||||
|
backupInProgress = false
|
||||||
|
|
||||||
|
snackbarHostState.showSnackbar(messageFromResult(context, responseStatus))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isCancelling by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
if (backupInProgress) {
|
||||||
|
BackupDialog(onDismiss = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
isCancelling = true
|
||||||
|
|
||||||
|
backupJob?.cancelAndJoin()
|
||||||
|
|
||||||
|
isCancelling = false
|
||||||
|
backupInProgress = false
|
||||||
|
}
|
||||||
|
}, isCancelling)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,607 @@
|
|||||||
|
package com.geode.launcher.preferences.components
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.activity.compose.LocalActivity
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.defaultMinSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.selection.toggleable
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.geode.launcher.R
|
||||||
|
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||||
|
import com.geode.launcher.ui.theme.Typography
|
||||||
|
import com.geode.launcher.utils.LabelledText
|
||||||
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
|
import com.geode.launcher.utils.ProfileManager
|
||||||
|
import kotlin.collections.get
|
||||||
|
import kotlin.math.log10
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
|
|
||||||
|
fun toggleSetting(context: Context, preferenceKey: PreferenceUtils.Key): Boolean {
|
||||||
|
val preferences = PreferenceUtils.get(context)
|
||||||
|
|
||||||
|
return preferences.toggleBoolean(preferenceKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSetting(context: Context, preferenceKey: PreferenceUtils.Key): Boolean {
|
||||||
|
val preferences = PreferenceUtils.get(context)
|
||||||
|
|
||||||
|
return preferences.getBoolean(preferenceKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun OptionsGroup(title: String? = null, content: @Composable () -> Unit) {
|
||||||
|
Column {
|
||||||
|
if (title != null) {
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style = Typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsSelectCard(
|
||||||
|
title: String,
|
||||||
|
dialogTitle: String,
|
||||||
|
preferenceKey: PreferenceUtils.Key,
|
||||||
|
options: Map<Int, String>,
|
||||||
|
extraSelectBehavior: ((Int) -> Unit)? = null
|
||||||
|
) {
|
||||||
|
val preferenceValue by PreferenceUtils.useIntPreference(preferenceKey)
|
||||||
|
|
||||||
|
var showDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
OptionsCard(
|
||||||
|
title = { OptionsTitle(title = title) },
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable(
|
||||||
|
onClick = {
|
||||||
|
showDialog = true
|
||||||
|
},
|
||||||
|
role = Role.Button
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(options[preferenceValue] ?: preferenceValue.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDialog) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
SelectDialog(
|
||||||
|
title = dialogTitle,
|
||||||
|
onDismissRequest = {
|
||||||
|
showDialog = false
|
||||||
|
},
|
||||||
|
onSelect = { selected ->
|
||||||
|
showDialog = false
|
||||||
|
PreferenceUtils.get(context)
|
||||||
|
.setInt(preferenceKey, selected)
|
||||||
|
extraSelectBehavior?.invoke(selected)
|
||||||
|
},
|
||||||
|
initialValue = preferenceValue,
|
||||||
|
) {
|
||||||
|
options.forEach { (k, v) ->
|
||||||
|
SelectOption(name = v, value = k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsStringSelectCard(
|
||||||
|
title: String,
|
||||||
|
dialogTitle: String,
|
||||||
|
preferenceKey: PreferenceUtils.Key,
|
||||||
|
options: Map<String, String>,
|
||||||
|
extraSelectBehavior: ((String?) -> Unit)? = null,
|
||||||
|
description: String? = null,
|
||||||
|
) {
|
||||||
|
val preferenceValue by PreferenceUtils.useStringPreference(preferenceKey)
|
||||||
|
|
||||||
|
var showDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
OptionsCard(
|
||||||
|
title = { OptionsTitle(title = title, description = description) },
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable(
|
||||||
|
onClick = {
|
||||||
|
showDialog = true
|
||||||
|
},
|
||||||
|
role = Role.Button
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(options[preferenceValue] ?: preferenceValue ?: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDialog) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
SelectDialog(
|
||||||
|
title = dialogTitle,
|
||||||
|
onDismissRequest = {
|
||||||
|
showDialog = false
|
||||||
|
},
|
||||||
|
onSelect = { selected ->
|
||||||
|
showDialog = false
|
||||||
|
PreferenceUtils.get(context)
|
||||||
|
.setString(preferenceKey, selected)
|
||||||
|
extraSelectBehavior?.invoke(selected)
|
||||||
|
},
|
||||||
|
initialValue = preferenceValue,
|
||||||
|
) {
|
||||||
|
options.forEach { (k, v) ->
|
||||||
|
SelectOption(name = v, value = k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsStringCard(
|
||||||
|
title: String,
|
||||||
|
dialogTitle: String,
|
||||||
|
preferenceKey: PreferenceUtils.Key,
|
||||||
|
filterInput: ((String) -> String)? = null
|
||||||
|
) {
|
||||||
|
var preferenceValue by PreferenceUtils.useStringPreference(preferenceKey)
|
||||||
|
|
||||||
|
var showDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
OptionsCard(
|
||||||
|
title = {
|
||||||
|
OptionsTitle(title = title, description = preferenceValue)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable(
|
||||||
|
onClick = {
|
||||||
|
showDialog = true
|
||||||
|
},
|
||||||
|
role = Role.Button
|
||||||
|
)
|
||||||
|
) { }
|
||||||
|
|
||||||
|
if (showDialog) {
|
||||||
|
StringDialog(
|
||||||
|
title = dialogTitle,
|
||||||
|
onDismissRequest = { showDialog = false },
|
||||||
|
onSelect = {
|
||||||
|
preferenceValue = it
|
||||||
|
showDialog = false
|
||||||
|
},
|
||||||
|
initialValue = preferenceValue ?: "",
|
||||||
|
filterInput = filterInput
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsFPSCard(
|
||||||
|
title: String,
|
||||||
|
dialogTitle: String,
|
||||||
|
preferenceKey: PreferenceUtils.Key,
|
||||||
|
maxFrameRate: Int,
|
||||||
|
description: String? = null,
|
||||||
|
) {
|
||||||
|
var preferenceValue by PreferenceUtils.useIntPreference(preferenceKey)
|
||||||
|
|
||||||
|
var showDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
OptionsCard(
|
||||||
|
title = { OptionsTitle(title = title, description = description) },
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable(
|
||||||
|
onClick = {
|
||||||
|
showDialog = true
|
||||||
|
},
|
||||||
|
role = Role.Button
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if (preferenceValue == 0) {
|
||||||
|
Text(stringResource(R.string.preference_limit_framerate_default))
|
||||||
|
} else {
|
||||||
|
Text(stringResource(R.string.preference_limit_framerate_value, preferenceValue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDialog) {
|
||||||
|
FrameRateDialog(
|
||||||
|
title = dialogTitle,
|
||||||
|
onDismissRequest = { showDialog = false },
|
||||||
|
onSelect = {
|
||||||
|
preferenceValue = it
|
||||||
|
showDialog = false
|
||||||
|
},
|
||||||
|
initialValue = preferenceValue,
|
||||||
|
maxFrameRate = maxFrameRate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsRangeCard(
|
||||||
|
title: String,
|
||||||
|
dialogTitle: String,
|
||||||
|
preferenceKey: PreferenceUtils.Key,
|
||||||
|
labelSuffix: String,
|
||||||
|
range: IntRange,
|
||||||
|
scale: Int,
|
||||||
|
step: Int,
|
||||||
|
description: String? = null,
|
||||||
|
children: @Composable () -> Unit = {}
|
||||||
|
) {
|
||||||
|
var preferenceValue by PreferenceUtils.useIntPreference(preferenceKey)
|
||||||
|
|
||||||
|
var showDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
OptionsCard(
|
||||||
|
title = {
|
||||||
|
OptionsTitle(title = title, description = description)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable(
|
||||||
|
onClick = {
|
||||||
|
showDialog = true
|
||||||
|
},
|
||||||
|
role = Role.Button
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
val precision = log10(scale.toFloat()).roundToInt()
|
||||||
|
val scaledValue = preferenceValue / scale.toFloat()
|
||||||
|
|
||||||
|
Text("%.${precision}f$labelSuffix".format(scaledValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDialog) {
|
||||||
|
RangeDialog(
|
||||||
|
title = dialogTitle,
|
||||||
|
onDismissRequest = { showDialog = false },
|
||||||
|
onSelect = {
|
||||||
|
preferenceValue = it
|
||||||
|
showDialog = false
|
||||||
|
},
|
||||||
|
initialValue = preferenceValue,
|
||||||
|
range = range,
|
||||||
|
scale = scale,
|
||||||
|
labelSuffix = labelSuffix,
|
||||||
|
step = step,
|
||||||
|
children = children,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ProfileCreateCard() {
|
||||||
|
var showDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
OptionsButton(
|
||||||
|
title = stringResource(R.string.preference_profiles_create),
|
||||||
|
icon = {
|
||||||
|
Icon(painterResource(R.drawable.icon_person_add), contentDescription = null)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
showDialog = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDialog) {
|
||||||
|
ProfileCreateDialog(onDismissRequest = {
|
||||||
|
showDialog = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ProfileSelectCard() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val profileManager = ProfileManager.get(context)
|
||||||
|
|
||||||
|
val currentProfileId = remember {
|
||||||
|
profileManager.getCurrentProfile()
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentProfileName = remember {
|
||||||
|
val savedProfile = currentProfileId
|
||||||
|
if (savedProfile == null) {
|
||||||
|
"Default"
|
||||||
|
} else {
|
||||||
|
profileManager.getProfile(savedProfile)
|
||||||
|
?.name ?: savedProfile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val clearedProfiles = remember { mutableStateListOf<String>() }
|
||||||
|
|
||||||
|
val profileList by profileManager.storedProfiles.collectAsState()
|
||||||
|
|
||||||
|
var showDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
OptionsButton(
|
||||||
|
title = stringResource(R.string.preference_profiles_select),
|
||||||
|
description = stringResource(R.string.preference_profiles_current, currentProfileName)
|
||||||
|
) {
|
||||||
|
showDialog = true
|
||||||
|
}
|
||||||
|
|
||||||
|
val activity = LocalActivity.current
|
||||||
|
if (showDialog) {
|
||||||
|
SelectDialog(
|
||||||
|
title = stringResource(R.string.preference_profiles_select),
|
||||||
|
onDismissRequest = {
|
||||||
|
showDialog = false
|
||||||
|
},
|
||||||
|
onSelect = {
|
||||||
|
showDialog = false
|
||||||
|
|
||||||
|
val selectedProfileId = it.takeIf { it.isNotEmpty() }
|
||||||
|
|
||||||
|
if (clearedProfiles.isNotEmpty()) {
|
||||||
|
profileManager.deleteProfiles(clearedProfiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentProfileId != selectedProfileId) {
|
||||||
|
profileManager
|
||||||
|
.setCurrentProfile(selectedProfileId)
|
||||||
|
|
||||||
|
activity?.run {
|
||||||
|
packageManager.getLaunchIntentForPackage(packageName)?.also { intent ->
|
||||||
|
val mainIntent = Intent.makeRestartActivityTask(intent.component)
|
||||||
|
startActivity(mainIntent)
|
||||||
|
exitProcess(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
initialValue = currentProfileId ?: "",
|
||||||
|
) {
|
||||||
|
SelectOption("Default", "")
|
||||||
|
|
||||||
|
profileList.forEach { profile ->
|
||||||
|
val path = profile.path
|
||||||
|
|
||||||
|
SelectOption(
|
||||||
|
stringResource(R.string.preference_profiles_select_value, profile.name, path),
|
||||||
|
path,
|
||||||
|
enabled = !clearedProfiles.contains(path),
|
||||||
|
leadingContent = { selected ->
|
||||||
|
IconButton(onClick = {
|
||||||
|
if (clearedProfiles.contains(path)) {
|
||||||
|
clearedProfiles.remove(path)
|
||||||
|
} else {
|
||||||
|
clearedProfiles.add(path)
|
||||||
|
}
|
||||||
|
}, enabled = !selected) {
|
||||||
|
if (clearedProfiles.contains(path)) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_undo),
|
||||||
|
contentDescription = stringResource(R.string.preference_profiles_delete_undo_alt)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_delete),
|
||||||
|
contentDescription = stringResource(R.string.preference_profiles_delete_alt)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun OptionsButton(title: String, description: String? = null, icon: (@Composable () -> Unit)? = null, displayInline: Boolean = false, onClick: () -> Unit) {
|
||||||
|
OptionsCard(
|
||||||
|
title = {
|
||||||
|
OptionsTitle(
|
||||||
|
title = title,
|
||||||
|
description = description.takeIf { !displayInline },
|
||||||
|
icon = icon
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable(onClick = onClick, role = Role.Button),
|
||||||
|
wrapContent = true
|
||||||
|
) {
|
||||||
|
if (displayInline && description != null) {
|
||||||
|
Text(description, textAlign = TextAlign.End)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun OptionsLabel(title: String, description: String? = null, icon: (@Composable () -> Unit)? = null, displayInline: Boolean = false) {
|
||||||
|
OptionsCard(
|
||||||
|
title = {
|
||||||
|
OptionsTitle(
|
||||||
|
title = title,
|
||||||
|
description = description.takeIf { !displayInline },
|
||||||
|
icon = icon
|
||||||
|
)
|
||||||
|
},
|
||||||
|
wrapContent = true
|
||||||
|
) {
|
||||||
|
if (displayInline && description != null) {
|
||||||
|
Text(description, textAlign = TextAlign.End)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsCard(title: String, description: String? = null, icon: (@Composable () -> Unit)? = null, asCard: Boolean = true, preferenceKey: PreferenceUtils.Key) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val settingEnabled = remember {
|
||||||
|
mutableStateOf(getSetting(context, preferenceKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
OptionsCard(
|
||||||
|
title = {
|
||||||
|
OptionsTitle(
|
||||||
|
title = title,
|
||||||
|
description = description,
|
||||||
|
icon = icon
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.toggleable(
|
||||||
|
value = settingEnabled.value,
|
||||||
|
onValueChange = { settingEnabled.value = toggleSetting(context, preferenceKey) },
|
||||||
|
role = Role.Switch,
|
||||||
|
),
|
||||||
|
backgroundColor = if (asCard)
|
||||||
|
MaterialTheme.colorScheme.surfaceContainer
|
||||||
|
else MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
|
) {
|
||||||
|
Switch(
|
||||||
|
checked = settingEnabled.value,
|
||||||
|
onCheckedChange = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun OptionsTitle(modifier: Modifier = Modifier, title: String, description: String? = null, icon: (@Composable () -> Unit)? = null) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (icon != null) {
|
||||||
|
icon()
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Text(title)
|
||||||
|
if (!description.isNullOrEmpty()) {
|
||||||
|
Text(
|
||||||
|
description,
|
||||||
|
style = Typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun OptionsCard(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
wrapContent: Boolean = false,
|
||||||
|
backgroundColor: Color = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
title: @Composable () -> Unit,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 1.dp)
|
||||||
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.then(modifier)
|
||||||
|
.background(backgroundColor)
|
||||||
|
.defaultMinSize(minHeight = 64.dp)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
Arrangement.SpaceBetween,
|
||||||
|
Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
if (!wrapContent) {
|
||||||
|
Box(modifier = Modifier.weight(1.0f)) {
|
||||||
|
title()
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.size(12.dp))
|
||||||
|
} else {
|
||||||
|
title()
|
||||||
|
}
|
||||||
|
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SafeModeDialog(onDismiss: () -> Unit, onLaunch: () -> Unit) {
|
||||||
|
AlertDialog(
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_shield_alert),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
title = { Text(stringResource(R.string.safe_mode_enable_title)) },
|
||||||
|
text = { Text(stringResource(R.string.safe_mode_enable_description)) },
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.message_box_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onLaunch) {
|
||||||
|
Text(stringResource(R.string.message_box_continue))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismissRequest = onDismiss
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun InlineText(label: String, icon: @Composable (() -> Unit)? = null, modifier: Modifier = Modifier) {
|
||||||
|
LabelledText(label = label, icon = icon, modifier = modifier.padding(horizontal = 16.dp, vertical = 4.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun OptionsCardPreview() {
|
||||||
|
GeodeLauncherTheme {
|
||||||
|
OptionsGroup(title = "Preview Group") {
|
||||||
|
SettingsCard(
|
||||||
|
title = "Load files from /test",
|
||||||
|
description = "Very long testing description goes here. It is incredibly long, it should wrap onto a new line.",
|
||||||
|
preferenceKey = PreferenceUtils.Key.BLACK_BACKGROUND
|
||||||
|
)
|
||||||
|
SettingsCard(
|
||||||
|
title = "Testing option 2",
|
||||||
|
preferenceKey = PreferenceUtils.Key.LOAD_AUTOMATICALLY
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,490 @@
|
|||||||
|
package com.geode.launcher.preferences.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import com.geode.launcher.R
|
||||||
|
import com.geode.launcher.ui.theme.Typography
|
||||||
|
import com.geode.launcher.utils.Profile
|
||||||
|
import com.geode.launcher.utils.ProfileManager
|
||||||
|
import kotlin.math.log10
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun StringDialog(
|
||||||
|
title: String,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onSelect: (String) -> Unit,
|
||||||
|
initialValue: String,
|
||||||
|
filterInput: ((String) -> String)? = null
|
||||||
|
) {
|
||||||
|
var enteredValue by remember { mutableStateOf(initialValue) }
|
||||||
|
|
||||||
|
BaseDialog(
|
||||||
|
onDismissRequest = { onDismissRequest() },
|
||||||
|
title = title,
|
||||||
|
content = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = enteredValue,
|
||||||
|
onValueChange = {
|
||||||
|
enteredValue = filterInput?.invoke(it) ?: it
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Ascii,
|
||||||
|
autoCorrectEnabled = false
|
||||||
|
),
|
||||||
|
trailingIcon = {
|
||||||
|
if (enteredValue.isNotEmpty()) {
|
||||||
|
IconButton(onClick = { enteredValue = "" }) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_close_small),
|
||||||
|
contentDescription = stringResource(R.string.preference_text_clear)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { onSelect(enteredValue) }) {
|
||||||
|
Text(stringResource(R.string.message_box_accept))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismissRequest) {
|
||||||
|
Text(stringResource(R.string.message_box_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RangeDialog(
|
||||||
|
title: String,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onSelect: (Int) -> Unit,
|
||||||
|
labelSuffix: String,
|
||||||
|
initialValue: Int,
|
||||||
|
range: IntRange,
|
||||||
|
scale: Int,
|
||||||
|
step: Int,
|
||||||
|
children: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
var enteredValue by remember {
|
||||||
|
mutableFloatStateOf(initialValue / scale.toFloat())
|
||||||
|
}
|
||||||
|
|
||||||
|
BaseDialog(
|
||||||
|
onDismissRequest = { onDismissRequest() },
|
||||||
|
title = title,
|
||||||
|
content = {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
val precision = log10(scale.toFloat()).roundToInt()
|
||||||
|
|
||||||
|
Slider(
|
||||||
|
value = enteredValue,
|
||||||
|
onValueChange = { enteredValue = it },
|
||||||
|
valueRange = (range.first.toFloat()/scale)..(range.last.toFloat()/scale),
|
||||||
|
steps = ((range.last - range.first) - 1) / step,
|
||||||
|
modifier = Modifier.weight(1.0f)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text("%.${precision}f$labelSuffix".format(enteredValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
children()
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { onSelect((enteredValue * scale).roundToInt()) }) {
|
||||||
|
Text(stringResource(R.string.message_box_accept))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismissRequest) {
|
||||||
|
Text(stringResource(R.string.message_box_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FrameRateDialog(
|
||||||
|
title: String,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onSelect: (Int) -> Unit,
|
||||||
|
initialValue: Int,
|
||||||
|
maxFrameRate: Int,
|
||||||
|
) {
|
||||||
|
var enteredValue by remember {
|
||||||
|
mutableStateOf(
|
||||||
|
if (initialValue == 0) maxFrameRate.toString()
|
||||||
|
else initialValue.toString()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val minFrameRate = 5
|
||||||
|
val currentValue = enteredValue.toIntOrNull()
|
||||||
|
val maximumReached = currentValue != null && currentValue > maxFrameRate
|
||||||
|
val minimumReached = currentValue != null && currentValue < minFrameRate
|
||||||
|
|
||||||
|
BaseDialog(
|
||||||
|
onDismissRequest = { onDismissRequest() },
|
||||||
|
title = title,
|
||||||
|
content = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
IconButton(onClick = {
|
||||||
|
val prevValue = max((currentValue ?: 0) - 5, minFrameRate)
|
||||||
|
enteredValue = prevValue.toString()
|
||||||
|
}, enabled = currentValue == null || currentValue > minFrameRate) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_remove),
|
||||||
|
contentDescription = stringResource(R.string.preference_limit_framerate_subtract)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = enteredValue,
|
||||||
|
onValueChange = {
|
||||||
|
enteredValue = it
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Number,
|
||||||
|
),
|
||||||
|
suffix = {
|
||||||
|
Text(stringResource(R.string.preference_limit_framerate_suffix))
|
||||||
|
},
|
||||||
|
label = {
|
||||||
|
Text(stringResource(R.string.preference_limit_framerate_label))
|
||||||
|
},
|
||||||
|
isError = minimumReached || maximumReached,
|
||||||
|
modifier = Modifier.weight(1.0f),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
|
||||||
|
IconButton(onClick = {
|
||||||
|
val nextValue = min((currentValue ?: 0) + 5, maxFrameRate)
|
||||||
|
enteredValue = nextValue.toString()
|
||||||
|
}, enabled = currentValue == null || currentValue < maxFrameRate) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_add),
|
||||||
|
contentDescription = stringResource(R.string.preference_limit_framerate_add)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(onClick = { onSelect(0) }) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_delete),
|
||||||
|
contentDescription = stringResource(R.string.preference_limit_framerate_reset)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (minimumReached) {
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.preference_limit_framerate_error_min, minFrameRate),
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
)
|
||||||
|
} else if (maximumReached) {
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
R.string.preference_limit_framerate_error_max,
|
||||||
|
maxFrameRate
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
// automatically disable if already max
|
||||||
|
if (currentValue == maxFrameRate) {
|
||||||
|
onSelect(0)
|
||||||
|
} else {
|
||||||
|
onSelect(currentValue ?: 0)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = !minimumReached && !maximumReached && currentValue != null
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.message_box_accept))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismissRequest) {
|
||||||
|
Text(stringResource(R.string.message_box_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ProfileCreateDialog(onDismissRequest: () -> Unit) {
|
||||||
|
var enteredValue by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
val filename = enteredValue.take(16)
|
||||||
|
.lowercase()
|
||||||
|
.map {
|
||||||
|
if ("qwertyuiopasdfghjklzxcvbnm1234567890-_.".contains(it))
|
||||||
|
it else '_'
|
||||||
|
}
|
||||||
|
.joinToString("")
|
||||||
|
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
val minimumReached = enteredValue.isEmpty() || filename.isEmpty()
|
||||||
|
|
||||||
|
val profileManager = ProfileManager.get(context)
|
||||||
|
val currentProfiles = remember {
|
||||||
|
profileManager.getProfiles().map { it.path }.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
val isDuplicate = currentProfiles.contains(filename)
|
||||||
|
|
||||||
|
BaseDialog(
|
||||||
|
onDismissRequest = { onDismissRequest() },
|
||||||
|
title = stringResource(R.string.preference_profiles_create),
|
||||||
|
content = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = enteredValue,
|
||||||
|
onValueChange = {
|
||||||
|
enteredValue = it
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Text,
|
||||||
|
),
|
||||||
|
label = {
|
||||||
|
Text(stringResource(R.string.preference_profiles_create_name))
|
||||||
|
},
|
||||||
|
isError = isDuplicate,
|
||||||
|
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isDuplicate) {
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
|
||||||
|
Text(stringResource(R.string.preference_profiles_create_duplicate))
|
||||||
|
} else if (!minimumReached) {
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
|
||||||
|
Text(stringResource(R.string.preference_profiles_create_info, filename))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
ProfileManager.get(context).storeProfile(Profile(filename, enteredValue))
|
||||||
|
onDismissRequest()
|
||||||
|
},
|
||||||
|
enabled = !minimumReached && !isDuplicate
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.preference_profiles_create_action))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismissRequest) {
|
||||||
|
Text(stringResource(R.string.message_box_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BaseDialog(
|
||||||
|
title: String,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
dismissButton: @Composable () -> Unit,
|
||||||
|
confirmButton: @Composable () -> Unit,
|
||||||
|
content: @Composable (ColumnScope.() -> Unit),
|
||||||
|
) {
|
||||||
|
Dialog(onDismissRequest = onDismissRequest) {
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
// styling a dialog is actually a little hard if you're doing what i'm doing
|
||||||
|
// maybe there's a better way to make these padding values...
|
||||||
|
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style = Typography.titleLarge,
|
||||||
|
modifier = Modifier.padding(
|
||||||
|
start = 28.dp,
|
||||||
|
top = 24.dp,
|
||||||
|
bottom = 12.dp
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
content()
|
||||||
|
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(
|
||||||
|
bottom = 16.dp,
|
||||||
|
end = 16.dp,
|
||||||
|
top = 4.dp
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
dismissButton()
|
||||||
|
confirmButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal val LocalSelectValue = compositionLocalOf<Any> { 0 }
|
||||||
|
internal val LocalSelectSetValue = staticCompositionLocalOf<(Any) -> Unit> { {} }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun <T> SelectOption(name: String, value: T, enabled: Boolean = true, leadingContent: @Composable (Boolean) -> Unit = {}) {
|
||||||
|
val currentValue = LocalSelectValue.current
|
||||||
|
val setValue = LocalSelectSetValue.current
|
||||||
|
|
||||||
|
val isSelected = currentValue.equals(value)
|
||||||
|
|
||||||
|
// do not give the row or column padding!! it messes up the selection effect
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(
|
||||||
|
onClick = { setValue(value as Any) },
|
||||||
|
role = Role.RadioButton,
|
||||||
|
enabled = enabled
|
||||||
|
)
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
) {
|
||||||
|
Row(modifier = Modifier.weight(1.0f, true), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
RadioButton(
|
||||||
|
selected = isSelected,
|
||||||
|
onClick = { setValue(value as Any) },
|
||||||
|
enabled = enabled
|
||||||
|
)
|
||||||
|
Text(name, style = Typography.bodyMedium)
|
||||||
|
}
|
||||||
|
|
||||||
|
leadingContent(isSelected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun <T> SelectDialog(
|
||||||
|
title: String,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onSelect: (T) -> Unit,
|
||||||
|
initialValue: T,
|
||||||
|
options: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val (selectedValue, setSelectedValue) = remember { mutableStateOf(initialValue) }
|
||||||
|
|
||||||
|
BaseDialog(
|
||||||
|
title = title,
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismissRequest) {
|
||||||
|
Text(stringResource(R.string.message_box_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { onSelect(selectedValue) }) {
|
||||||
|
Text(stringResource(R.string.message_box_accept))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalSelectValue provides (selectedValue as Any),
|
||||||
|
LocalSelectSetValue provides ({
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
setSelectedValue(it as T)
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
options()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.geode.launcher.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.geode.launcher.R
|
||||||
|
|
||||||
|
val robotoMonoFamily = FontFamily(
|
||||||
|
Font(R.font.robotomono_medium, FontWeight.Medium),
|
||||||
|
Font(R.font.robotomono_regular, FontWeight.Normal)
|
||||||
|
)
|
||||||
|
|
||||||
|
val quicksandFamily = FontFamily(
|
||||||
|
Font(R.font.quicksand_semibold, FontWeight.SemiBold)
|
||||||
|
)
|
||||||
|
|
||||||
|
val googleSansFamily = FontFamily(
|
||||||
|
Font(R.font.googlesans_medium, FontWeight.Medium)
|
||||||
|
)
|
||||||
|
|
||||||
|
val launcherTitleStyle = TextStyle(
|
||||||
|
fontFamily = quicksandFamily,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 32.sp,
|
||||||
|
letterSpacing = 0.sp
|
||||||
|
)
|
||||||
|
|
||||||
|
val sapphireTitleStyle = TextStyle(
|
||||||
|
fontFamily = googleSansFamily,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 32.sp,
|
||||||
|
letterSpacing = 0.sp
|
||||||
|
)
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.geode.launcher.ui.theme
|
package com.geode.launcher.ui.theme
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -9,35 +8,20 @@ import androidx.compose.material3.dynamicDarkColorScheme
|
|||||||
import androidx.compose.material3.dynamicLightColorScheme
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
import androidx.compose.material3.lightColorScheme
|
import androidx.compose.material3.lightColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.SideEffect
|
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.toArgb
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalView
|
|
||||||
import androidx.core.view.ViewCompat
|
|
||||||
import androidx.core.view.WindowCompat
|
|
||||||
|
|
||||||
private val DarkColorScheme = darkColorScheme(
|
private val DarkColorScheme = darkColorScheme(
|
||||||
primary = Purple80,
|
primary = Purple80,
|
||||||
secondary = PurpleGrey80,
|
secondary = PurpleGrey80,
|
||||||
tertiary = Pink80
|
tertiary = Pink80
|
||||||
)
|
)
|
||||||
|
|
||||||
private val LightColorScheme = lightColorScheme(
|
private val LightColorScheme = lightColorScheme(
|
||||||
primary = Purple40,
|
primary = Purple40,
|
||||||
secondary = PurpleGrey40,
|
secondary = PurpleGrey40,
|
||||||
tertiary = Pink40
|
tertiary = Pink40
|
||||||
|
|
||||||
/* Other default colors to override
|
|
||||||
background = Color(0xFFFFFBFE),
|
|
||||||
surface = Color(0xFFFFFBFE),
|
|
||||||
onPrimary = Color.White,
|
|
||||||
onSecondary = Color.White,
|
|
||||||
onTertiary = Color.White,
|
|
||||||
onBackground = Color(0xFF1C1B1F),
|
|
||||||
onSurface = Color(0xFF1C1B1F),
|
|
||||||
*/
|
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class Theme {
|
enum class Theme {
|
||||||
@@ -79,24 +63,6 @@ fun GeodeLauncherTheme(
|
|||||||
else -> LightColorScheme
|
else -> LightColorScheme
|
||||||
}
|
}
|
||||||
|
|
||||||
val view = LocalView.current
|
|
||||||
|
|
||||||
if (!view.isInEditMode) {
|
|
||||||
SideEffect {
|
|
||||||
with ((view.context as Activity).window) {
|
|
||||||
WindowCompat.setDecorFitsSystemWindows(this, false)
|
|
||||||
|
|
||||||
statusBarColor = Color.Transparent.toArgb()
|
|
||||||
navigationBarColor = Color.Transparent.toArgb()
|
|
||||||
|
|
||||||
WindowCompat.getInsetsController(this, view).apply {
|
|
||||||
isAppearanceLightStatusBars = theme == Theme.LIGHT
|
|
||||||
isAppearanceLightNavigationBars = theme == Theme.LIGHT
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
colorScheme = colorScheme,
|
colorScheme = colorScheme,
|
||||||
typography = Typography,
|
typography = Typography,
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
package com.geode.launcher.updater
|
||||||
|
|
||||||
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Asset(
|
||||||
|
val url: String,
|
||||||
|
val id: Int,
|
||||||
|
val name: String,
|
||||||
|
val size: Int,
|
||||||
|
val createdAt: Instant,
|
||||||
|
val updatedAt: Instant,
|
||||||
|
val browserDownloadUrl: String,
|
||||||
|
val digest: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Release(
|
||||||
|
val url: String,
|
||||||
|
val id: Int,
|
||||||
|
val targetCommitish: String,
|
||||||
|
val tagName: String,
|
||||||
|
val createdAt: Instant,
|
||||||
|
val publishedAt: Instant,
|
||||||
|
val assets: List<Asset>,
|
||||||
|
val htmlUrl: String,
|
||||||
|
val body: String?,
|
||||||
|
val name: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LoaderPlatformDownload(
|
||||||
|
val url: String,
|
||||||
|
val hash: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LoaderDownload(
|
||||||
|
val win: LoaderPlatformDownload,
|
||||||
|
val mac: LoaderPlatformDownload,
|
||||||
|
val android32: LoaderPlatformDownload,
|
||||||
|
val android64: LoaderPlatformDownload,
|
||||||
|
val ios: LoaderPlatformDownload,
|
||||||
|
val resources: LoaderPlatformDownload,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LoaderVersion(
|
||||||
|
val tag: String,
|
||||||
|
val version: String,
|
||||||
|
val createdAt: Instant,
|
||||||
|
val commitHash: String,
|
||||||
|
val prerelease: Boolean,
|
||||||
|
val downloads: LoaderDownload,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LoaderPayload<T>(
|
||||||
|
val payload: T?,
|
||||||
|
val error: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DownloadableAsset(
|
||||||
|
val url: String,
|
||||||
|
val filename: String,
|
||||||
|
val size: Long? = null,
|
||||||
|
val hash: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun mapDownload(download: Asset): DownloadableAsset {
|
||||||
|
val hash = if (download.digest?.startsWith("sha256:") == true)
|
||||||
|
download.digest.removePrefix("sha256:")
|
||||||
|
else null
|
||||||
|
|
||||||
|
return DownloadableAsset(
|
||||||
|
url = download.browserDownloadUrl,
|
||||||
|
filename = download.name,
|
||||||
|
size = download.size.toLong(),
|
||||||
|
hash = hash,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class Downloadable {
|
||||||
|
/**
|
||||||
|
* Human-readable representation of the current version.
|
||||||
|
*/
|
||||||
|
abstract fun getDescription(): String
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unique value that refers to the current version, used for comparison.
|
||||||
|
*/
|
||||||
|
abstract fun getDescriptor(): Long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release download link for the current platform
|
||||||
|
*/
|
||||||
|
abstract fun getDownload(): DownloadableAsset?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resources download link for current platform.
|
||||||
|
*/
|
||||||
|
abstract fun getResourcesDownload(): DownloadableAsset?
|
||||||
|
}
|
||||||
|
|
||||||
|
class DownloadableGitHubLoaderRelease(private val release: Release) : Downloadable() {
|
||||||
|
override fun getDescription(): String {
|
||||||
|
if (release.tagName == "nightly") {
|
||||||
|
// get the commit from the assets
|
||||||
|
// otherwise, a separate request is needed to get the hash (ew)
|
||||||
|
val asset = release.assets.first()
|
||||||
|
val commit = asset.name.substring(6..12)
|
||||||
|
|
||||||
|
return "nightly-$commit"
|
||||||
|
}
|
||||||
|
|
||||||
|
return release.tagName
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getDescriptor(): Long {
|
||||||
|
return release.createdAt.epochSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getGitHubDownload(): Asset? {
|
||||||
|
// try to find an asset that matches the architecture first
|
||||||
|
val platform = LaunchUtils.platformName
|
||||||
|
|
||||||
|
val releaseSuffix = "$platform.zip"
|
||||||
|
return release.assets.find {
|
||||||
|
it.name.endsWith(releaseSuffix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getDownload(): DownloadableAsset? {
|
||||||
|
val download = getGitHubDownload() ?: return null
|
||||||
|
return mapDownload(download)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getResourcesDownload(): DownloadableAsset? {
|
||||||
|
val download = release.assets.find {
|
||||||
|
it.name == "resources.zip"
|
||||||
|
} ?: return null
|
||||||
|
|
||||||
|
return mapDownload(download)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DownloadableLauncherRelease(val release: Release) : Downloadable() {
|
||||||
|
override fun getDescription(): String {
|
||||||
|
return release.tagName
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getDescriptor(): Long {
|
||||||
|
return release.createdAt.epochSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getGitHubDownload(): Asset? {
|
||||||
|
val platform = LaunchUtils.platformName
|
||||||
|
val use32BitPlatform = platform == "android32"
|
||||||
|
|
||||||
|
return release.assets.find { asset ->
|
||||||
|
/* you know it's good when you pull out the truth table
|
||||||
|
* u32bp | contains | found
|
||||||
|
* 1 | 1 | 1
|
||||||
|
* 0 | 1 | 0
|
||||||
|
* 1 | 0 | 0
|
||||||
|
* 0 | 0 | 1
|
||||||
|
* surprise! it's an xnor
|
||||||
|
*/
|
||||||
|
asset.name.endsWith("apk") && asset.name.contains("android32") == use32BitPlatform
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getDownload(): DownloadableAsset? {
|
||||||
|
val download = getGitHubDownload() ?: return null
|
||||||
|
return mapDownload(download)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getResourcesDownload(): DownloadableAsset? {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DownloadableLoaderRelease(private val version: LoaderVersion) : Downloadable() {
|
||||||
|
override fun getDescription(): String {
|
||||||
|
return version.tag
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getDescriptor(): Long {
|
||||||
|
return version.createdAt.epochSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getDownload(): DownloadableAsset {
|
||||||
|
val data = if (LaunchUtils.is64bit) version.downloads.android64 else version.downloads.android32
|
||||||
|
val filename = "geode-${version.tag}-${LaunchUtils.platformName}.zip"
|
||||||
|
return DownloadableAsset(
|
||||||
|
url = data.url,
|
||||||
|
filename = filename,
|
||||||
|
hash = data.hash,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getResourcesDownload(): DownloadableAsset {
|
||||||
|
val data = version.downloads.resources
|
||||||
|
val filename = "resources.zip"
|
||||||
|
return DownloadableAsset(
|
||||||
|
url = data.url,
|
||||||
|
filename = filename,
|
||||||
|
hash = data.hash,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,585 @@
|
|||||||
|
package com.geode.launcher.updater
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import com.geode.launcher.BuildConfig
|
||||||
|
import com.geode.launcher.utils.DownloadUtils
|
||||||
|
import com.geode.launcher.utils.GamePackageUtils
|
||||||
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import okhttp3.Cache
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okio.HashingSink
|
||||||
|
import okio.blackholeSink
|
||||||
|
import okio.buffer
|
||||||
|
import okio.sink
|
||||||
|
import okio.source
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileNotFoundException
|
||||||
|
import java.io.InterruptedIOException
|
||||||
|
import java.io.IOException
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
|
private const val TAG_LATEST = "latest"
|
||||||
|
private const val TAG_BETA = "prerelease"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton to manage Geode updates, from update checking to downloading.
|
||||||
|
*/
|
||||||
|
class ReleaseManager private constructor(
|
||||||
|
private val applicationContext: Context,
|
||||||
|
private val httpClient: OkHttpClient,
|
||||||
|
private val releaseRepository: ReleaseRepository = ReleaseRepository(httpClient)
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private lateinit var managerInstance: ReleaseManager
|
||||||
|
|
||||||
|
fun get(context: Context): ReleaseManager {
|
||||||
|
if (!Companion::managerInstance.isInitialized) {
|
||||||
|
val applicationContext = context.applicationContext
|
||||||
|
|
||||||
|
val httpClient = OkHttpClient.Builder()
|
||||||
|
.cache(Cache(
|
||||||
|
applicationContext.cacheDir,
|
||||||
|
maxSize = 10L * 1024L * 1024L // 10mb cache size
|
||||||
|
))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
managerInstance = ReleaseManager(
|
||||||
|
applicationContext,
|
||||||
|
httpClient
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return managerInstance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class ReleaseManagerState {
|
||||||
|
data object InUpdateCheck : ReleaseManagerState()
|
||||||
|
data class Failure(val exception: Exception) : ReleaseManagerState()
|
||||||
|
data class InDownload(val downloaded: Long, val outOf: Long?) : ReleaseManagerState()
|
||||||
|
data class Finished(val hasUpdated: Boolean = false) : ReleaseManagerState()
|
||||||
|
}
|
||||||
|
|
||||||
|
class UpdateException(reason: Reason? = null, cause: Throwable? = null) : Exception(reason?.name, cause) {
|
||||||
|
enum class Reason {
|
||||||
|
EXTERNAL_FILE_IN_USE,
|
||||||
|
HASH_VALIDATION_FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
var reason: Reason? = reason
|
||||||
|
private set
|
||||||
|
}
|
||||||
|
|
||||||
|
private var updateJob: Job? = null
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow<ReleaseManagerState>(ReleaseManagerState.Finished())
|
||||||
|
val uiState = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
val isInUpdate: Boolean
|
||||||
|
get() = _uiState.value !is ReleaseManagerState.Failure && _uiState.value !is ReleaseManagerState.Finished
|
||||||
|
|
||||||
|
private val _availableLauncherUpdateTag = MutableStateFlow<String?>(null)
|
||||||
|
val availableLauncherUpdateTag = _availableLauncherUpdateTag.asStateFlow()
|
||||||
|
|
||||||
|
private val _availableLauncherUpdateDetails = MutableStateFlow<DownloadableLauncherRelease?>(null)
|
||||||
|
val availableLauncherUpdateDetails = _availableLauncherUpdateDetails.asStateFlow()
|
||||||
|
|
||||||
|
var dismissedLauncherUpdate = false
|
||||||
|
|
||||||
|
var skipStateUpdate = false
|
||||||
|
|
||||||
|
private fun sendError(e: Exception) {
|
||||||
|
skipStateUpdate = true
|
||||||
|
_uiState.value = ReleaseManagerState.Failure(e)
|
||||||
|
|
||||||
|
// ignore cancellation, it's good actually
|
||||||
|
if (e !is CancellationException && e !is InterruptedIOException) {
|
||||||
|
Log.w("Geode", "Release download has failed:")
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mapSelectedReleaseToTag(): String {
|
||||||
|
val sharedPreferences = PreferenceUtils.get(applicationContext)
|
||||||
|
val releaseChannel = sharedPreferences.getInt(PreferenceUtils.Key.RELEASE_CHANNEL_TAG)
|
||||||
|
|
||||||
|
return when (releaseChannel) {
|
||||||
|
2 -> "nightly"
|
||||||
|
1 -> TAG_BETA
|
||||||
|
else -> TAG_LATEST
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getBestReleaseForGameVersion(gameVersion: Long): String? = when {
|
||||||
|
gameVersion >= 40L -> mapSelectedReleaseToTag()
|
||||||
|
gameVersion == 39L -> "v3.9.3"
|
||||||
|
gameVersion == 38L -> "v2.0.0-beta.27"
|
||||||
|
gameVersion == 37L -> "v2.0.0-beta.4"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getLatestRelease(): Downloadable? {
|
||||||
|
if (!GamePackageUtils.isGameInstalled(applicationContext.packageManager)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val gameVersion = GamePackageUtils.getGameVersionCode(applicationContext.packageManager)
|
||||||
|
val preferences = PreferenceUtils.get(applicationContext)
|
||||||
|
|
||||||
|
val targetTag = preferences.getInt(PreferenceUtils.Key.RELEASE_CHANNEL_TAG)
|
||||||
|
|
||||||
|
return when (targetTag) {
|
||||||
|
2 -> releaseRepository.getReleaseByTag("nightly")
|
||||||
|
1 -> releaseRepository.getLatestIndexRelease(gameVersion, true)
|
||||||
|
else -> releaseRepository.getLatestIndexRelease(gameVersion, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun downloadLauncherUpdate(release: Downloadable): Result<File> {
|
||||||
|
val download = release.getDownload()
|
||||||
|
?: return Result.failure(FileNotFoundException())
|
||||||
|
|
||||||
|
val outputDirectory = applicationContext.filesDir
|
||||||
|
val outputFile = File(outputDirectory, download.filename)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val hash = DownloadUtils.downloadFile(
|
||||||
|
httpClient,
|
||||||
|
download.url,
|
||||||
|
outputFile
|
||||||
|
)
|
||||||
|
|
||||||
|
if (download.hash != null && download.hash != hash) {
|
||||||
|
Log.w("ReleaseManager", "downloadLauncherUpdate failed: found $hash but expected ${download.hash}")
|
||||||
|
outputFile.delete()
|
||||||
|
throw UpdateException(UpdateException.Reason.HASH_VALIDATION_FAILED)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return Result.failure(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.success(outputFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun performResourceDownload(resourceAsset: DownloadableAsset, initialSize: Long): Boolean {
|
||||||
|
val guessSize = if (resourceAsset.size == null) null else initialSize + resourceAsset.size
|
||||||
|
val guessInitial = if (guessSize == null) 0 else initialSize
|
||||||
|
_uiState.value = ReleaseManagerState.InDownload(guessInitial, guessSize)
|
||||||
|
|
||||||
|
val outputDir = getTempResourcesDirectory()
|
||||||
|
val outputPath = outputDir.path
|
||||||
|
|
||||||
|
val finalDir = LaunchUtils.getGeodeResourcesDirectory(applicationContext)
|
||||||
|
|
||||||
|
val downloadFile = getTempFile("resources.zip")
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!outputDir.exists()) {
|
||||||
|
outputDir.mkdirs()
|
||||||
|
}
|
||||||
|
|
||||||
|
val hash = DownloadUtils.downloadFile(
|
||||||
|
httpClient,
|
||||||
|
resourceAsset.url,
|
||||||
|
downloadFile,
|
||||||
|
onProgress = { progress, outOf ->
|
||||||
|
if (!skipStateUpdate)
|
||||||
|
_uiState.value = ReleaseManagerState.InDownload(initialSize + progress, outOf + initialSize)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (resourceAsset.hash != null && resourceAsset.hash != hash) {
|
||||||
|
Log.w("ReleaseManager", "performResourceDownload failed: found $hash but expected ${resourceAsset.hash}")
|
||||||
|
throw UpdateException(UpdateException.Reason.HASH_VALIDATION_FAILED)
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadFile.inputStream().use { zip ->
|
||||||
|
DownloadUtils.copyZipStreamToDirectory(
|
||||||
|
zip,
|
||||||
|
outputDir
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalDir.exists()) {
|
||||||
|
finalDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!outputDir.renameTo(finalDir)) {
|
||||||
|
println("Failed to rename temporary directory!")
|
||||||
|
DownloadUtils.copyDirectory(outputDir, finalDir)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
sendError(e)
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
val tempPathClone = File(outputPath)
|
||||||
|
try {
|
||||||
|
if (downloadFile.exists()) downloadFile.delete()
|
||||||
|
if (tempPathClone.exists()) tempPathClone.deleteRecursively()
|
||||||
|
} catch (_: IOException) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun performUpdate(release: Downloadable) {
|
||||||
|
skipStateUpdate = false
|
||||||
|
|
||||||
|
val releaseAsset = release.getDownload()
|
||||||
|
if (releaseAsset == null) {
|
||||||
|
val noAssetException = Exception("missing Android download")
|
||||||
|
_uiState.value = ReleaseManagerState.Failure(noAssetException)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val resourcesAsset = release.getResourcesDownload()
|
||||||
|
|
||||||
|
var releaseSize = releaseAsset.size ?: 0L
|
||||||
|
val resourcesSize = resourcesAsset?.size ?: 0L
|
||||||
|
|
||||||
|
// set an initial download size
|
||||||
|
val initialSize = if (releaseAsset.size == null && resourcesAsset?.size == null) null else releaseSize + resourcesSize
|
||||||
|
_uiState.value = ReleaseManagerState.InDownload(0, initialSize)
|
||||||
|
|
||||||
|
val outputFile = getTempFile(LaunchUtils.geodeFilename)
|
||||||
|
// clone the file instance as renameTo may move the original file
|
||||||
|
val tempFilePath = outputFile.path
|
||||||
|
|
||||||
|
val geodeFile = getGeodeOutputPath()
|
||||||
|
|
||||||
|
val downloadFile = getTempFile("geode.zip")
|
||||||
|
|
||||||
|
try {
|
||||||
|
// tempFile should be in same path as geodeFile
|
||||||
|
val geodeParent = geodeFile.parentFile
|
||||||
|
if (geodeParent != null && !geodeParent.exists()) {
|
||||||
|
geodeParent.mkdirs()
|
||||||
|
}
|
||||||
|
|
||||||
|
val hash = DownloadUtils.downloadFile(
|
||||||
|
httpClient,
|
||||||
|
releaseAsset.url,
|
||||||
|
downloadFile,
|
||||||
|
onProgress = { progress, outOf ->
|
||||||
|
if (!skipStateUpdate)
|
||||||
|
_uiState.value = ReleaseManagerState.InDownload(progress, outOf + resourcesSize)
|
||||||
|
|
||||||
|
releaseSize = max(outOf, releaseSize)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (releaseAsset.hash != null && releaseAsset.hash != hash) {
|
||||||
|
Log.w("ReleaseManager", "performUpdate failed: found $hash but expected ${releaseAsset.hash}")
|
||||||
|
throw UpdateException(UpdateException.Reason.HASH_VALIDATION_FAILED)
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadFile.inputStream().use { zip ->
|
||||||
|
DownloadUtils.extractFileFromZipStream(
|
||||||
|
zip,
|
||||||
|
outputFile,
|
||||||
|
geodeFile.name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// work around a permission issue from adb push
|
||||||
|
if (geodeFile.exists()) {
|
||||||
|
geodeFile.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
val renameSuccessful = runCatching {
|
||||||
|
outputFile.renameTo(geodeFile)
|
||||||
|
}.getOrDefault(false)
|
||||||
|
|
||||||
|
if (!renameSuccessful) {
|
||||||
|
// attempt a manual copy if rename fails for whatever reason
|
||||||
|
DownloadUtils.copyFile(outputFile, geodeFile)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
sendError(e)
|
||||||
|
return
|
||||||
|
} finally {
|
||||||
|
val tempFileClone = File(tempFilePath)
|
||||||
|
try {
|
||||||
|
if (downloadFile.exists()) downloadFile.delete()
|
||||||
|
if (tempFileClone.exists()) tempFileClone.delete()
|
||||||
|
} catch (_: IOException) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resourcesAsset != null) {
|
||||||
|
if (!performResourceDownload(resourcesAsset, releaseSize)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extraction performed
|
||||||
|
updatePreferences(release)
|
||||||
|
|
||||||
|
val currentTime = Clock.System.now().toEpochMilliseconds()
|
||||||
|
PreferenceUtils.get(applicationContext)
|
||||||
|
.setLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME, currentTime)
|
||||||
|
|
||||||
|
_uiState.value = ReleaseManagerState.Finished(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fileWasExternallyModified(modifiedOnly: Boolean = false): Boolean {
|
||||||
|
// make sure geode is still here. just in case
|
||||||
|
val geodeFile = getGeodeOutputPath()
|
||||||
|
if (!geodeFile.exists()) {
|
||||||
|
return !modifiedOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
val sharedPreferences = PreferenceUtils.get(applicationContext)
|
||||||
|
|
||||||
|
val originalFileHash = sharedPreferences.getString(PreferenceUtils.Key.CURRENT_RELEASE_MODIFIED)
|
||||||
|
?: return true
|
||||||
|
|
||||||
|
val currentFileHash = computeFileHash(geodeFile)
|
||||||
|
return originalFileHash != currentFileHash
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getLatestLauncherUpdate(): DownloadableLauncherRelease? {
|
||||||
|
if (BuildConfig.GOOGLE_PLAY_BUILD) {
|
||||||
|
// don't check for updates on Google Play builds
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
releaseRepository.getLatestLauncherRelease()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
sendError(e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun checkLauncherUpdate() {
|
||||||
|
if (BuildConfig.GOOGLE_PLAY_BUILD) return
|
||||||
|
|
||||||
|
val launcherRelease = getLatestLauncherUpdate() ?: return
|
||||||
|
|
||||||
|
// unconditionally track it, we're not using it for checks and it might be good to have
|
||||||
|
_availableLauncherUpdateDetails.value = launcherRelease
|
||||||
|
|
||||||
|
val launcherTag = launcherRelease.release.tagName
|
||||||
|
|
||||||
|
PreferenceUtils.get(applicationContext)
|
||||||
|
.setString(PreferenceUtils.Key.LAST_LAUNCHER_UPDATE, launcherTag)
|
||||||
|
|
||||||
|
if (launcherTag != BuildConfig.VERSION_NAME) {
|
||||||
|
_availableLauncherUpdateTag.value = launcherTag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun checkCachedLauncherUpdate() {
|
||||||
|
if (BuildConfig.GOOGLE_PLAY_BUILD) return
|
||||||
|
|
||||||
|
val lastUpdate = PreferenceUtils.get(applicationContext)
|
||||||
|
.getString(PreferenceUtils.Key.LAST_LAUNCHER_UPDATE) ?: return
|
||||||
|
|
||||||
|
if (lastUpdate != BuildConfig.VERSION_NAME) {
|
||||||
|
_availableLauncherUpdateTag.value = lastUpdate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun downloadLatestLauncherUpdate(): Result<File> {
|
||||||
|
if (BuildConfig.GOOGLE_PLAY_BUILD) {
|
||||||
|
return Result.failure(UnsupportedOperationException(
|
||||||
|
"Launcher updates are not supported on Google Play builds"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
val update = _availableLauncherUpdateDetails.value
|
||||||
|
?: getLatestLauncherUpdate()
|
||||||
|
?: return Result.failure(FileNotFoundException())
|
||||||
|
|
||||||
|
return downloadLauncherUpdate(update)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shouldUseCache(): Boolean {
|
||||||
|
if (BuildConfig.GOOGLE_PLAY_BUILD) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
val sharedPreferences = PreferenceUtils.get(applicationContext)
|
||||||
|
if (sharedPreferences.getBoolean(PreferenceUtils.Key.DISABLE_UPDATE_CACHE)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val lastCheckTime = sharedPreferences.getLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME)
|
||||||
|
|
||||||
|
// only check for updates if it's been over 15 minutes since last check
|
||||||
|
val checkMinTime = Clock.System.now().minus(15.minutes).toEpochMilliseconds()
|
||||||
|
return lastCheckTime > checkMinTime && !fileWasExternallyModified()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun afterUseCachedUpdate() {
|
||||||
|
checkCachedLauncherUpdate()
|
||||||
|
_uiState.tryEmit(ReleaseManagerState.Finished())
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun checkForNewRelease(isManual: Boolean = false) {
|
||||||
|
if (BuildConfig.GOOGLE_PLAY_BUILD) {
|
||||||
|
_uiState.value = ReleaseManagerState.Finished()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val sharedPreferences = PreferenceUtils.get(applicationContext)
|
||||||
|
|
||||||
|
if (!isManual && shouldUseCache()) {
|
||||||
|
afterUseCachedUpdate()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val release = try {
|
||||||
|
getLatestRelease()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
sendError(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
checkLauncherUpdate()
|
||||||
|
|
||||||
|
if (release == null) {
|
||||||
|
val currentTime = Clock.System.now().toEpochMilliseconds()
|
||||||
|
sharedPreferences.setLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME, currentTime)
|
||||||
|
|
||||||
|
_uiState.value = ReleaseManagerState.Finished()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentVersion = sharedPreferences.getLong(PreferenceUtils.Key.CURRENT_VERSION_TIMESTAMP)
|
||||||
|
val latestVersion = release.getDescriptor()
|
||||||
|
|
||||||
|
// check if an update is needed
|
||||||
|
if (latestVersion == currentVersion && !fileWasExternallyModified()) {
|
||||||
|
val currentTime = Clock.System.now().toEpochMilliseconds()
|
||||||
|
sharedPreferences.setLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME, currentTime)
|
||||||
|
|
||||||
|
_uiState.value = ReleaseManagerState.Finished()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val allowOverwriting = !sharedPreferences.getBoolean(PreferenceUtils.Key.DEVELOPER_MODE) || isManual
|
||||||
|
|
||||||
|
// check if the file was externally modified
|
||||||
|
if (!allowOverwriting && fileWasExternallyModified(true)) {
|
||||||
|
sendError(UpdateException(UpdateException.Reason.EXTERNAL_FILE_IN_USE))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
performUpdate(release)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updatePreferences(release: Downloadable) {
|
||||||
|
val sharedPreferences = PreferenceUtils.get(applicationContext)
|
||||||
|
|
||||||
|
sharedPreferences.setString(
|
||||||
|
PreferenceUtils.Key.CURRENT_VERSION_TAG,
|
||||||
|
release.getDescription()
|
||||||
|
)
|
||||||
|
|
||||||
|
sharedPreferences.setLong(
|
||||||
|
PreferenceUtils.Key.CURRENT_VERSION_TIMESTAMP,
|
||||||
|
release.getDescriptor()
|
||||||
|
)
|
||||||
|
|
||||||
|
// store hash as modified time is unreliable (don't know why)
|
||||||
|
val outputFile = getGeodeOutputPath()
|
||||||
|
val fileHash = computeFileHash(outputFile)
|
||||||
|
|
||||||
|
sharedPreferences.setString(PreferenceUtils.Key.CURRENT_RELEASE_MODIFIED, fileHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun computeFileHash(file: File): String {
|
||||||
|
val sink = HashingSink.md5(blackholeSink())
|
||||||
|
file.source().buffer().use {
|
||||||
|
it.readAll(sink)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sink.hash.hex()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getGeodeOutputPath(): File {
|
||||||
|
val geodeName = LaunchUtils.geodeFilename
|
||||||
|
val geodeDirectory = LaunchUtils.getBaseDirectory(applicationContext)
|
||||||
|
|
||||||
|
return File(geodeDirectory, geodeName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createRandomString(): String {
|
||||||
|
val alphabet = ('A'..'Z') + ('a'..'z') + ('0'..'9')
|
||||||
|
return buildString(8) {
|
||||||
|
repeat(8) { append(alphabet.random()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getTempFile(filename: String): File {
|
||||||
|
val geodeDirectory = LaunchUtils.getBaseDirectory(applicationContext)
|
||||||
|
|
||||||
|
// warning!! while File::createTempFile may look tempting, a certain brand of phones has a messed up implementation of it
|
||||||
|
// so we're making a temp file manually (as long as it doesn't collide with the geode download, it's okay)
|
||||||
|
|
||||||
|
val suffix = createRandomString()
|
||||||
|
val tmpName = "tmp-$suffix.$filename"
|
||||||
|
|
||||||
|
val tempFile = File(geodeDirectory, tmpName)
|
||||||
|
|
||||||
|
return tempFile
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getTempResourcesDirectory(): File {
|
||||||
|
val finalDir = LaunchUtils.getGeodeResourcesDirectory(applicationContext)
|
||||||
|
val suffix = createRandomString()
|
||||||
|
val tempDirName = "tmp-$suffix-geode.loader"
|
||||||
|
|
||||||
|
val parentDir = finalDir.parentFile
|
||||||
|
return if (parentDir != null) {
|
||||||
|
File(finalDir.parentFile, tempDirName)
|
||||||
|
} else {
|
||||||
|
File(tempDirName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancels the current update job.
|
||||||
|
*/
|
||||||
|
suspend fun cancelUpdate() {
|
||||||
|
updateJob?.cancelAndJoin()
|
||||||
|
updateJob = null
|
||||||
|
|
||||||
|
_uiState.value = ReleaseManagerState.Finished()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules a new update checking job.
|
||||||
|
* @return Flow that tracks the state of the update.
|
||||||
|
*/
|
||||||
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
|
fun checkForUpdates(isManual: Boolean = false): StateFlow<ReleaseManagerState> {
|
||||||
|
if (!isInUpdate) {
|
||||||
|
if (isManual || !shouldUseCache()) {
|
||||||
|
_uiState.value = ReleaseManagerState.InUpdateCheck
|
||||||
|
updateJob = GlobalScope.launch {
|
||||||
|
checkForNewRelease(isManual)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
afterUseCachedUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return _uiState.asStateFlow()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package com.geode.launcher.updater
|
||||||
|
|
||||||
|
import com.geode.launcher.BuildConfig
|
||||||
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import kotlinx.datetime.until
|
||||||
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonNamingStrategy
|
||||||
|
import kotlinx.serialization.json.okio.decodeFromBufferedSource
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import okhttp3.OkHttp
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.coroutines.executeAsync
|
||||||
|
import java.io.IOException
|
||||||
|
import java.net.URL
|
||||||
|
import kotlin.time.ExperimentalTime
|
||||||
|
|
||||||
|
class ReleaseRepository(private val httpClient: OkHttpClient) {
|
||||||
|
companion object {
|
||||||
|
private const val GITHUB_API_BASE = "https://api.github.com"
|
||||||
|
private const val GITHUB_API_HEADER = "X-GitHub-Api-Version"
|
||||||
|
private const val GITHUB_API_VERSION = "2022-11-28"
|
||||||
|
|
||||||
|
private const val GITHUB_RATELIMIT_REMAINING = "x-ratelimit-remaining"
|
||||||
|
private const val GITHUB_RATELIMIT_RESET = "x-ratelimit-reset"
|
||||||
|
|
||||||
|
private const val GEODE_API_BASE = "https://api.geode-sdk.org/v1"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val userAgent = "okhttp/${OkHttp.VERSION} (launcher=${BuildConfig.VERSION_NAME};arch=${LaunchUtils.applicationArchitecture})"
|
||||||
|
|
||||||
|
suspend fun getLatestLauncherRelease(): DownloadableLauncherRelease? {
|
||||||
|
val releasePath = "$GITHUB_API_BASE/repos/geode-sdk/android-launcher/releases/latest"
|
||||||
|
|
||||||
|
val url = URL(releasePath)
|
||||||
|
|
||||||
|
return getReleaseByUrl(url)?.let(::DownloadableLauncherRelease)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getLatestGeodeRelease(): DownloadableGitHubLoaderRelease? {
|
||||||
|
val releasePath = "$GITHUB_API_BASE/repos/geode-sdk/geode/releases/latest"
|
||||||
|
val url = URL(releasePath)
|
||||||
|
|
||||||
|
return getReleaseByUrl(url)?.let(::DownloadableGitHubLoaderRelease)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getReleaseByTag(tag: String): DownloadableGitHubLoaderRelease? {
|
||||||
|
val releasePath = "$GITHUB_API_BASE/repos/geode-sdk/geode/releases/tags/$tag"
|
||||||
|
val url = URL(releasePath)
|
||||||
|
|
||||||
|
return getReleaseByUrl(url)?.let(::DownloadableGitHubLoaderRelease)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalSerializationApi::class, ExperimentalTime::class)
|
||||||
|
suspend fun getLatestGeodePreRelease(): DownloadableGitHubLoaderRelease? {
|
||||||
|
val releasesUrl = "$GITHUB_API_BASE/repos/geode-sdk/geode/releases?per_page=2"
|
||||||
|
val url = URL(releasesUrl)
|
||||||
|
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.addHeader("Accept", "application/json")
|
||||||
|
.addHeader(GITHUB_API_HEADER, GITHUB_API_VERSION)
|
||||||
|
.addHeader("User-Agent", userAgent)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val call = httpClient.newCall(request)
|
||||||
|
|
||||||
|
return call.executeAsync().use { response ->
|
||||||
|
when (response.code) {
|
||||||
|
200 -> {
|
||||||
|
val format = Json {
|
||||||
|
namingStrategy = JsonNamingStrategy.SnakeCase
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
}
|
||||||
|
|
||||||
|
val release = format.decodeFromBufferedSource<List<Release>>(
|
||||||
|
response.body.source()
|
||||||
|
)
|
||||||
|
|
||||||
|
(release.find { it.tagName != "nightly" })
|
||||||
|
?.let(::DownloadableGitHubLoaderRelease)
|
||||||
|
}
|
||||||
|
403 -> {
|
||||||
|
// determine if the error code is a ratelimit
|
||||||
|
// (github docs say it sends 429 too, but haven't seen that)
|
||||||
|
|
||||||
|
val limitRemaining = response.headers.get(GITHUB_RATELIMIT_REMAINING)?.toInt()
|
||||||
|
val limitReset = response.headers.get(GITHUB_RATELIMIT_RESET)?.toLong()
|
||||||
|
|
||||||
|
if (limitRemaining == 0 && limitReset != null) {
|
||||||
|
// handle ratelimit with a custom error
|
||||||
|
// there's also a retry-after header but again, haven't seen
|
||||||
|
val resetTime = Instant.fromEpochSeconds(limitReset, 0L)
|
||||||
|
val msg = generateRateLimitMessage(resetTime)
|
||||||
|
|
||||||
|
throw IOException(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw IOException("response 403: ${response.body.string()}")
|
||||||
|
}
|
||||||
|
404 -> {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
throw IOException("unknown response ${response.code}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTime::class)
|
||||||
|
private fun generateRateLimitMessage(resetTime: Instant): String {
|
||||||
|
val currentTime = Clock.System.now()
|
||||||
|
val resetDelay = currentTime.until(resetTime, DateTimeUnit.SECOND)
|
||||||
|
|
||||||
|
val formattedWait = "${resetDelay / 60}m"
|
||||||
|
|
||||||
|
return "api ratelimit reached, try again in ${formattedWait}"
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalSerializationApi::class, ExperimentalTime::class)
|
||||||
|
private suspend fun getReleaseByUrl(url: URL): Release? {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.addHeader("Accept", "application/json")
|
||||||
|
.addHeader(GITHUB_API_HEADER, GITHUB_API_VERSION)
|
||||||
|
.addHeader("User-Agent", userAgent)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val call = httpClient.newCall(request)
|
||||||
|
return call.executeAsync().use { response ->
|
||||||
|
when (response.code) {
|
||||||
|
200 -> {
|
||||||
|
val format = Json {
|
||||||
|
namingStrategy = JsonNamingStrategy.SnakeCase
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
}
|
||||||
|
|
||||||
|
val release = format.decodeFromBufferedSource<Release>(
|
||||||
|
response.body.source()
|
||||||
|
)
|
||||||
|
|
||||||
|
release
|
||||||
|
}
|
||||||
|
403 -> {
|
||||||
|
// determine if the error code is a ratelimit
|
||||||
|
// (github docs say it sends 429 too, but haven't seen that)
|
||||||
|
|
||||||
|
val limitRemaining = response.headers[GITHUB_RATELIMIT_REMAINING]?.toInt()
|
||||||
|
val limitReset = response.headers[GITHUB_RATELIMIT_RESET]?.toLong()
|
||||||
|
|
||||||
|
if (limitRemaining == 0 && limitReset != null) {
|
||||||
|
// handle ratelimit with a custom error
|
||||||
|
// there's also a retry-after header but again, haven't seen
|
||||||
|
val resetTime = Instant.fromEpochSeconds(limitReset, 0L)
|
||||||
|
val msg = generateRateLimitMessage(resetTime)
|
||||||
|
|
||||||
|
throw IOException(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw IOException("response 403: ${response.body.string()}")
|
||||||
|
}
|
||||||
|
404 -> {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
throw IOException("unknown response ${response.code}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
|
suspend fun getLatestIndexRelease(gd: Long, prerelease: Boolean = false): DownloadableLoaderRelease? {
|
||||||
|
val url = "$GEODE_API_BASE/loader/versions/latest".toHttpUrlOrNull()!!
|
||||||
|
.newBuilder()
|
||||||
|
.addQueryParameter("gd", gd.toString())
|
||||||
|
.addQueryParameter("platform", "android")
|
||||||
|
.addQueryParameter("prerelease", prerelease.toString())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.addHeader("Accept", "application/json")
|
||||||
|
.addHeader("User-Agent", userAgent)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val call = httpClient.newCall(request)
|
||||||
|
return call.executeAsync().use { response ->
|
||||||
|
when (response.code) {
|
||||||
|
200 -> {
|
||||||
|
val format = Json {
|
||||||
|
namingStrategy = JsonNamingStrategy.SnakeCase
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
}
|
||||||
|
|
||||||
|
val body = format.decodeFromBufferedSource<LoaderPayload<LoaderVersion>>(
|
||||||
|
response.body.source()
|
||||||
|
)
|
||||||
|
|
||||||
|
val release = body.payload ?: throw IOException("index error: ${body.error}")
|
||||||
|
|
||||||
|
DownloadableLoaderRelease(release)
|
||||||
|
}
|
||||||
|
404 -> {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
throw IOException("unknown response ${response.code}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-5
@@ -1,4 +1,4 @@
|
|||||||
package com.geode.launcher.api
|
package com.geode.launcher.updater
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
@@ -7,7 +7,8 @@ import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.AP
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.lifecycle.viewmodel.initializer
|
import androidx.lifecycle.viewmodel.initializer
|
||||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
import com.geode.launcher.utils.ReleaseManager
|
import com.geode.launcher.BuildConfig
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -30,7 +31,7 @@ class ReleaseViewModel(private val application: Application): ViewModel() {
|
|||||||
sealed class ReleaseUIState {
|
sealed class ReleaseUIState {
|
||||||
data object InUpdateCheck : ReleaseUIState()
|
data object InUpdateCheck : ReleaseUIState()
|
||||||
data class Failure(val exception: Exception) : ReleaseUIState()
|
data class Failure(val exception: Exception) : ReleaseUIState()
|
||||||
data class InDownload(val downloaded: Long, val outOf: Long) : ReleaseUIState()
|
data class InDownload(val downloaded: Long, val outOf: Long?) : ReleaseUIState()
|
||||||
data class Finished(val hasUpdated: Boolean = false) : ReleaseUIState()
|
data class Finished(val hasUpdated: Boolean = false) : ReleaseUIState()
|
||||||
data class Cancelled(val isCancelling: Boolean = false) : ReleaseUIState()
|
data class Cancelled(val isCancelling: Boolean = false) : ReleaseUIState()
|
||||||
|
|
||||||
@@ -53,6 +54,8 @@ class ReleaseViewModel(private val application: Application): ViewModel() {
|
|||||||
val isInUpdate
|
val isInUpdate
|
||||||
get() = ReleaseManager.get(application).isInUpdate
|
get() = ReleaseManager.get(application).isInUpdate
|
||||||
|
|
||||||
|
val nextLauncherUpdate = ReleaseManager.get(application).availableLauncherUpdateTag
|
||||||
|
|
||||||
var hasPerformedCheck = false
|
var hasPerformedCheck = false
|
||||||
private set
|
private set
|
||||||
|
|
||||||
@@ -78,7 +81,7 @@ class ReleaseViewModel(private val application: Application): ViewModel() {
|
|||||||
fun useGlobalCheckState() {
|
fun useGlobalCheckState() {
|
||||||
hasPerformedCheck = true
|
hasPerformedCheck = true
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val releaseFlow = ReleaseManager.get(application)
|
val releaseFlow = ReleaseManager.get(application)
|
||||||
.uiState
|
.uiState
|
||||||
|
|
||||||
@@ -87,9 +90,13 @@ class ReleaseViewModel(private val application: Application): ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun runReleaseCheck(isManual: Boolean = false) {
|
fun runReleaseCheck(isManual: Boolean = false) {
|
||||||
|
if (BuildConfig.GOOGLE_PLAY_BUILD) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
hasPerformedCheck = true
|
hasPerformedCheck = true
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val releaseFlow = ReleaseManager.get(application)
|
val releaseFlow = ReleaseManager.get(application)
|
||||||
.checkForUpdates(isManual)
|
.checkForUpdates(isManual)
|
||||||
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.geode.launcher.utils
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.text.InlineTextContent
|
||||||
|
import androidx.compose.foundation.text.appendInlineContent
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.Placeholder
|
||||||
|
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LabelledText(label: String, icon: @Composable (() -> Unit)? = null, modifier: Modifier = Modifier) {
|
||||||
|
if (icon != null) {
|
||||||
|
val iconId = "inlineIcon"
|
||||||
|
val paddingId = "paddingIcon"
|
||||||
|
|
||||||
|
val labelText = buildAnnotatedString {
|
||||||
|
appendInlineContent(iconId, "[x]")
|
||||||
|
appendInlineContent(paddingId, " ")
|
||||||
|
append(label)
|
||||||
|
}
|
||||||
|
|
||||||
|
val inlineContent = mapOf(
|
||||||
|
Pair(iconId, InlineTextContent(
|
||||||
|
Placeholder(
|
||||||
|
width = 24.sp,
|
||||||
|
height = 24.sp,
|
||||||
|
placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
icon()
|
||||||
|
}),
|
||||||
|
Pair(paddingId, InlineTextContent(Placeholder(width = 6.sp, height = 24.sp, placeholderVerticalAlign = PlaceholderVerticalAlign.Center)) {
|
||||||
|
Spacer(modifier = Modifier.fillMaxWidth())
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(text = labelText, inlineContent = inlineContent, modifier = modifier)
|
||||||
|
} else {
|
||||||
|
Text(label, modifier = modifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,10 +13,14 @@ object Constants {
|
|||||||
@SuppressLint("SdCardPath")
|
@SuppressLint("SdCardPath")
|
||||||
const val GJ_DATA_DIR = "/data/data/${PACKAGE_NAME}"
|
const val GJ_DATA_DIR = "/data/data/${PACKAGE_NAME}"
|
||||||
|
|
||||||
const val SUPPORTED_VERSION_CODE = 37L
|
// anything below this version code is blocked from opening the game
|
||||||
const val SUPPORTED_VERSION_STRING = "2.2.13"
|
const val SUPPORTED_VERSION_CODE_MIN = 37L
|
||||||
|
|
||||||
const val LAUNCHER_KEY_RETURN_TITLE = "return_title"
|
// anything below this version code shows a warning on the main menu
|
||||||
const val LAUNCHER_KEY_RETURN_MESSAGE = "return_message"
|
const val SUPPORTED_VERSION_CODE_MIN_WARNING = 41L
|
||||||
|
|
||||||
|
// anything above this version code will show a warning when starting the game
|
||||||
|
const val SUPPORTED_VERSION_CODE = 41L
|
||||||
|
|
||||||
|
const val SUPPORTED_VERSION_STRING = "2.2.144"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.geode.launcher.utils
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import kotlin.math.ceil
|
||||||
|
import kotlin.math.floor
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
class ConstrainedFrameLayout(context: Context) : FrameLayout(context) {
|
||||||
|
// avoid running the aspect ratio fix when we don't need it
|
||||||
|
var aspectRatio: Float? = null
|
||||||
|
var zoom: Float? = null
|
||||||
|
var fitZoom: Boolean = false
|
||||||
|
|
||||||
|
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||||
|
var paddingX = 0.0f
|
||||||
|
var paddingY = 0.0f
|
||||||
|
|
||||||
|
val currentZoom = zoom
|
||||||
|
if (currentZoom != null) {
|
||||||
|
val width = MeasureSpec.getSize(widthMeasureSpec)
|
||||||
|
val height = MeasureSpec.getSize(heightMeasureSpec)
|
||||||
|
if (width == 0 || height == 0) {
|
||||||
|
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val calculatedWidth = width * currentZoom
|
||||||
|
val calculatedHeight = height * currentZoom
|
||||||
|
|
||||||
|
paddingX = (width - calculatedWidth)/2
|
||||||
|
paddingY = (height - calculatedHeight)/2
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentAspectRatio = aspectRatio
|
||||||
|
if (currentAspectRatio != null) {
|
||||||
|
val width = MeasureSpec.getSize(widthMeasureSpec)
|
||||||
|
val height = MeasureSpec.getSize(heightMeasureSpec)
|
||||||
|
if (width == 0 || height == 0) {
|
||||||
|
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val calculatedWidth = (height - paddingY * 2) * currentAspectRatio
|
||||||
|
val padding = (width - calculatedWidth) / 2
|
||||||
|
|
||||||
|
paddingX = padding
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paddingX > 0.0 || paddingY > 0.0) {
|
||||||
|
if (fitZoom) {
|
||||||
|
val width = MeasureSpec.getSize(widthMeasureSpec)
|
||||||
|
val height = MeasureSpec.getSize(heightMeasureSpec)
|
||||||
|
|
||||||
|
val calculatedHeight = (height - paddingY * 2)
|
||||||
|
val calculatedWidth = (width - paddingX * 2)
|
||||||
|
|
||||||
|
val zoomFactor = min(height / calculatedHeight, width / calculatedWidth)
|
||||||
|
|
||||||
|
scaleX = zoomFactor
|
||||||
|
scaleY = zoomFactor
|
||||||
|
}
|
||||||
|
|
||||||
|
setPadding(
|
||||||
|
ceil(paddingX).toInt(),
|
||||||
|
ceil(paddingY).toInt(),
|
||||||
|
floor(paddingX).toInt(),
|
||||||
|
floor(paddingY).toInt()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
super.onMeasure(
|
||||||
|
widthMeasureSpec,
|
||||||
|
heightMeasureSpec
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,37 +2,63 @@ package com.geode.launcher.utils
|
|||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.FileUtils
|
import android.os.FileUtils
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.runInterruptible
|
import kotlinx.coroutines.runInterruptible
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.Call
|
|
||||||
import okhttp3.Callback
|
|
||||||
import okhttp3.MediaType
|
import okhttp3.MediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.RequestBody
|
||||||
import okhttp3.ResponseBody
|
import okhttp3.ResponseBody
|
||||||
|
import okhttp3.coroutines.executeAsync
|
||||||
import okio.Buffer
|
import okio.Buffer
|
||||||
|
import okio.BufferedSink
|
||||||
import okio.BufferedSource
|
import okio.BufferedSource
|
||||||
|
import okio.FileSystem
|
||||||
|
import okio.ForwardingSink
|
||||||
import okio.ForwardingSource
|
import okio.ForwardingSource
|
||||||
|
import okio.HashingSink
|
||||||
|
import okio.Path.Companion.toOkioPath
|
||||||
|
import okio.Sink
|
||||||
import okio.Source
|
import okio.Source
|
||||||
import okio.buffer
|
import okio.buffer
|
||||||
|
import okio.sink
|
||||||
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import java.util.zip.ZipInputStream
|
import java.util.zip.ZipInputStream
|
||||||
import kotlin.coroutines.resume
|
|
||||||
import kotlin.coroutines.resumeWithException
|
|
||||||
|
|
||||||
|
|
||||||
typealias ProgressCallback = (progress: Long, outOf: Long) -> Unit
|
typealias ProgressCallback = (progress: Long, outOf: Long) -> Unit
|
||||||
|
|
||||||
object DownloadUtils {
|
object DownloadUtils {
|
||||||
|
suspend fun downloadFile(
|
||||||
|
httpClient: OkHttpClient,
|
||||||
|
url: String,
|
||||||
|
outputFile: File,
|
||||||
|
onProgress: ProgressCallback? = null,
|
||||||
|
): String = downloadStream(
|
||||||
|
httpClient,
|
||||||
|
url,
|
||||||
|
onProgress = onProgress
|
||||||
|
).use { body ->
|
||||||
|
val hashingSink = HashingSink.sha256(outputFile.sink().buffer())
|
||||||
|
hashingSink.use { sink ->
|
||||||
|
body.source().use { source ->
|
||||||
|
source.readAll(sink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hashingSink.hash.hex()
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun downloadStream(
|
suspend fun downloadStream(
|
||||||
httpClient: OkHttpClient,
|
httpClient: OkHttpClient,
|
||||||
url: String,
|
url: String,
|
||||||
onProgress: ProgressCallback? = null
|
onProgress: ProgressCallback? = null,
|
||||||
): InputStream {
|
): ResponseBody {
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url(url)
|
.url(url)
|
||||||
.build()
|
.build()
|
||||||
@@ -50,7 +76,7 @@ object DownloadUtils {
|
|||||||
val originalResponse = chain.proceed(chain.request())
|
val originalResponse = chain.proceed(chain.request())
|
||||||
originalResponse.newBuilder()
|
originalResponse.newBuilder()
|
||||||
.body(ProgressResponseBody(
|
.body(ProgressResponseBody(
|
||||||
originalResponse.body!!, onProgress
|
originalResponse.body, onProgress
|
||||||
))
|
))
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
@@ -59,50 +85,67 @@ object DownloadUtils {
|
|||||||
val progressClient = progressClientBuilder.build()
|
val progressClient = progressClientBuilder.build()
|
||||||
|
|
||||||
val call = progressClient.newCall(request)
|
val call = progressClient.newCall(request)
|
||||||
val response = call.executeCoroutine()
|
val response = call.executeAsync()
|
||||||
|
|
||||||
return response.body!!.byteStream()
|
val responseCode = response.code
|
||||||
|
if (responseCode != 200) {
|
||||||
|
response.close()
|
||||||
|
throw IOException("unexpected response ${response.code}")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.body
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun Call.executeCoroutine(): Response {
|
suspend fun copyZipStreamToDirectory(inputStream: InputStream, output: File) = runInterruptible {
|
||||||
return suspendCancellableCoroutine { continuation ->
|
ZipInputStream(inputStream).use { zipStream ->
|
||||||
this.enqueue(object : Callback {
|
if (output.exists()) output.deleteRecursively()
|
||||||
override fun onFailure(call: Call, e: IOException) {
|
output.mkdirs()
|
||||||
if (continuation.isCancelled) {
|
|
||||||
return
|
val canonicalOutput = output.canonicalPath
|
||||||
|
|
||||||
|
var entry = zipStream.nextEntry
|
||||||
|
while (entry != null) {
|
||||||
|
val destination = File(output, entry.name)
|
||||||
|
if (!destination.canonicalPath.startsWith(canonicalOutput)) {
|
||||||
|
throw IOException("attempted copy to outside of output directory: ${entry.name}")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (destination.isDirectory) {
|
||||||
|
destination.mkdirs()
|
||||||
|
} else {
|
||||||
|
destination.parentFile?.mkdirs()
|
||||||
|
|
||||||
|
destination.outputStream().use { destinationStream ->
|
||||||
|
zipStream.copyTo(destinationStream)
|
||||||
}
|
}
|
||||||
|
|
||||||
continuation.resumeWithException(e)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResponse(call: Call, response: Response) {
|
entry = zipStream.nextEntry
|
||||||
continuation.resume(response)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
continuation.invokeOnCancellation {
|
|
||||||
this.cancel()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun extractFileFromZipStream(inputStream: InputStream, outputStream: OutputStream, zipPath: String) {
|
/**
|
||||||
|
* Extracts a file named by zipPath from inputStream and copies it to outputStream
|
||||||
|
*
|
||||||
|
* inputStream and outputStream are both closed after this function executes
|
||||||
|
*/
|
||||||
|
suspend fun extractFileFromZipStream(inputStream: InputStream, output: File, zipPath: String) {
|
||||||
// note to self: ZipInputStreams are a little silly
|
// note to self: ZipInputStreams are a little silly
|
||||||
// (runInterruptible allows it to cancel, otherwise it waits for the stream to finish)
|
// (runInterruptible allows it to cancel, otherwise it waits for the stream to finish)
|
||||||
runInterruptible {
|
runInterruptible {
|
||||||
inputStream.use {
|
inputStream.use { input ->
|
||||||
outputStream.use {
|
output.outputStream().use { out ->
|
||||||
// nice indentation
|
// nice indentation
|
||||||
val zip = ZipInputStream(inputStream)
|
ZipInputStream(input).use { zip ->
|
||||||
zip.use {
|
var entry = zip.nextEntry
|
||||||
var entry = it.nextEntry
|
|
||||||
while (entry != null) {
|
while (entry != null) {
|
||||||
if (entry.name == zipPath) {
|
if (entry.name == zipPath) {
|
||||||
it.copyTo(outputStream)
|
zip.copyTo(out)
|
||||||
return@runInterruptible
|
return@runInterruptible
|
||||||
}
|
}
|
||||||
|
|
||||||
entry = it.nextEntry
|
entry = zip.nextEntry
|
||||||
}
|
}
|
||||||
|
|
||||||
// no matching entries found, throw exception
|
// no matching entries found, throw exception
|
||||||
@@ -113,24 +156,41 @@ object DownloadUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun copyFile(from: File, to: File) {
|
||||||
|
val fs = FileSystem.SYSTEM
|
||||||
|
|
||||||
|
val fromPath = from.toOkioPath()
|
||||||
|
val toPath = to.toOkioPath()
|
||||||
|
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
fs.copy(fromPath, toPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies data from inputStream to outputStream
|
||||||
|
*
|
||||||
|
* Both streams are closed upon the conclusion of this function.
|
||||||
|
*/
|
||||||
fun copyFile(inputStream: InputStream, outputStream: OutputStream) {
|
fun copyFile(inputStream: InputStream, outputStream: OutputStream) {
|
||||||
// gotta love copying
|
// gotta love copying
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
FileUtils.copy(inputStream, outputStream)
|
inputStream.use { input -> outputStream.use { output ->
|
||||||
|
FileUtils.copy(input, output)
|
||||||
|
}}
|
||||||
} else {
|
} else {
|
||||||
inputStream.use { input ->
|
inputStream.use { input -> outputStream.use { output ->
|
||||||
outputStream.use { output ->
|
input.copyTo(output)
|
||||||
val buffer = ByteArray(4 * 1024)
|
}}
|
||||||
while (true) {
|
|
||||||
val byteCount = input.read(buffer)
|
|
||||||
if (byteCount < 0) break
|
|
||||||
output.write(buffer, 0, byteCount)
|
|
||||||
}
|
|
||||||
output.flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun copyDirectory(sourceFile: File, outputFile: File) = withContext(Dispatchers.IO) {
|
||||||
|
sourceFile.copyRecursively(
|
||||||
|
outputFile,
|
||||||
|
overwrite = true
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// based on <https://github.com/square/okhttp/blob/master/samples/guide/src/main/java/okhttp3/recipes/Progress.java>
|
// based on <https://github.com/square/okhttp/blob/master/samples/guide/src/main/java/okhttp3/recipes/Progress.java>
|
||||||
@@ -172,4 +232,39 @@ private class ProgressResponseBody (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// now based on <https://github.com/square/okhttp/blob/master/samples/guide/src/main/java/okhttp3/recipes/UploadProgress.java#L84>
|
||||||
|
class ProgressRequestBody(
|
||||||
|
private val requestBody: RequestBody,
|
||||||
|
private val progressCallback: ProgressCallback
|
||||||
|
) : RequestBody() {
|
||||||
|
override fun contentType(): MediaType? {
|
||||||
|
return requestBody.contentType()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun contentLength(): Long {
|
||||||
|
return requestBody.contentLength()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun writeTo(sink: BufferedSink) {
|
||||||
|
val bufferedSink = this.sink(sink).buffer()
|
||||||
|
requestBody.writeTo(bufferedSink)
|
||||||
|
bufferedSink.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sink(sink: Sink): Sink {
|
||||||
|
return object : ForwardingSink(sink) {
|
||||||
|
var totalBytesWritten = 0L
|
||||||
|
|
||||||
|
override fun write(source: Buffer, byteCount: Long) {
|
||||||
|
super.write(source, byteCount)
|
||||||
|
totalBytesWritten += byteCount
|
||||||
|
progressCallback(
|
||||||
|
totalBytesWritten,
|
||||||
|
contentLength()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package com.geode.launcher.utils
|
||||||
|
|
||||||
|
import android.content.ContentUris
|
||||||
|
import android.content.Context
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Environment
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import java.io.File
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
|
||||||
|
class FileUtils {
|
||||||
|
// copied from https://stackoverflow.com/questions/17546101/get-real-path-for-uri-android
|
||||||
|
// i am actually very lazy to move this to a separate class
|
||||||
|
// hello zmx i moved it
|
||||||
|
companion object {
|
||||||
|
fun getRealPathFromURI(context: Context, uri: Uri): String? {
|
||||||
|
when {
|
||||||
|
// DocumentProvider
|
||||||
|
DocumentsContract.isDocumentUri(context, uri) -> {
|
||||||
|
when {
|
||||||
|
// ExternalStorageProvider
|
||||||
|
isExternalStorageDocument(uri) -> {
|
||||||
|
return handleExternalStorageDocument(context, uri)
|
||||||
|
}
|
||||||
|
isDownloadsDocument(uri) -> {
|
||||||
|
return handleDownloadsDocument(context, uri)
|
||||||
|
}
|
||||||
|
isMediaDocument(uri) -> {
|
||||||
|
return handleMediaDocument(context, uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: add the geode provider
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"content".equals(uri.scheme, ignoreCase = true) -> {
|
||||||
|
// Return the remote address
|
||||||
|
if (isGooglePhotosUri(uri)) return uri.lastPathSegment
|
||||||
|
// This happened with a directory
|
||||||
|
if (isExternalStorageDocument(uri)) {
|
||||||
|
return handleExternalStorageDocument(context, uri)
|
||||||
|
}
|
||||||
|
return getDataColumn(context, uri, null, null)
|
||||||
|
}
|
||||||
|
"file".equals(uri.scheme, ignoreCase = true) -> {
|
||||||
|
return uri.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleMediaDocument(context: Context, uri: Uri): String? {
|
||||||
|
val docId = DocumentsContract.getDocumentId(uri)
|
||||||
|
val split = docId.split(":").toTypedArray()
|
||||||
|
val type = split[0]
|
||||||
|
var contentUri: Uri? = null
|
||||||
|
when (type) {
|
||||||
|
"image" -> {
|
||||||
|
contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||||
|
}
|
||||||
|
"video" -> {
|
||||||
|
contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||||
|
}
|
||||||
|
"audio" -> {
|
||||||
|
contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val selection = "_id=?"
|
||||||
|
val selectionArgs = arrayOf(split[1])
|
||||||
|
return getDataColumn(
|
||||||
|
context,
|
||||||
|
contentUri,
|
||||||
|
selection,
|
||||||
|
selectionArgs
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleDownloadsDocument(context: Context, uri: Uri): String? {
|
||||||
|
val fileName = getFilePath(context, uri)
|
||||||
|
if (fileName != null) {
|
||||||
|
return Environment.getExternalStorageDirectory().toString() + "/Download/" + fileName
|
||||||
|
}
|
||||||
|
var id = DocumentsContract.getDocumentId(uri)
|
||||||
|
if (id.startsWith("raw:")) {
|
||||||
|
id = id.replaceFirst("raw:".toRegex(), "")
|
||||||
|
val file = File(id)
|
||||||
|
if (file.exists()) return id
|
||||||
|
}
|
||||||
|
val contentUri = ContentUris.withAppendedId("content://downloads/public_downloads".toUri(), java.lang.Long.valueOf(id))
|
||||||
|
return getDataColumn(context, contentUri, null, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleExternalStorageDocument(context: Context, uri: Uri): String {
|
||||||
|
val docId: String = if (DocumentsContract.isDocumentUri(context, uri)) {
|
||||||
|
DocumentsContract.getDocumentId(uri)
|
||||||
|
} else {
|
||||||
|
// please please please PLEASE let this be the only other case
|
||||||
|
DocumentsContract.getTreeDocumentId(uri)
|
||||||
|
}
|
||||||
|
val split = docId.split(":").toTypedArray()
|
||||||
|
val type = split[0]
|
||||||
|
// This is for checking Main Memory
|
||||||
|
return if ("primary".equals(type, ignoreCase = true)) {
|
||||||
|
if (split.size > 1) {
|
||||||
|
Environment.getExternalStorageDirectory().toString() + "/" + split[1]
|
||||||
|
} else {
|
||||||
|
Environment.getExternalStorageDirectory().toString() + "/"
|
||||||
|
}
|
||||||
|
// This is for checking SD Card
|
||||||
|
} else {
|
||||||
|
"storage" + "/" + docId.replace(":", "/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private fun getFilePath(context: Context, uri: Uri?): String? {
|
||||||
|
var cursor: Cursor? = null
|
||||||
|
val projection = arrayOf(
|
||||||
|
MediaStore.MediaColumns.DISPLAY_NAME
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
if (uri == null) return null
|
||||||
|
cursor = context.contentResolver.query(
|
||||||
|
uri, projection, null, null,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
if (cursor != null && cursor.moveToFirst()) {
|
||||||
|
val index = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)
|
||||||
|
val data = cursor.getString(index)
|
||||||
|
cursor.close()
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
cursor?.close()
|
||||||
|
} finally {
|
||||||
|
cursor?.close()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getDataColumn(context: Context, uri: Uri?, selection: String?,
|
||||||
|
selectionArgs: Array<String>?): String? {
|
||||||
|
var cursor: Cursor? = null
|
||||||
|
val column = "_data"
|
||||||
|
val projection = arrayOf(
|
||||||
|
column
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
if (uri == null) return null
|
||||||
|
cursor = context.contentResolver.query(
|
||||||
|
uri, projection, selection, selectionArgs,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
if (cursor != null && cursor.moveToFirst()) {
|
||||||
|
val index = cursor.getColumnIndexOrThrow(column)
|
||||||
|
val data = cursor.getString(index)
|
||||||
|
cursor.close()
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
cursor?.close()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param uri The Uri to check.
|
||||||
|
* @return Whether the Uri authority is ExternalStorageProvider.
|
||||||
|
*/
|
||||||
|
private fun isExternalStorageDocument(uri: Uri): Boolean {
|
||||||
|
return "com.android.externalstorage.documents" == uri.authority
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param uri The Uri to check.
|
||||||
|
* @return Whether the Uri authority is DownloadsProvider.
|
||||||
|
*/
|
||||||
|
private fun isDownloadsDocument(uri: Uri): Boolean {
|
||||||
|
return "com.android.providers.downloads.documents" == uri.authority
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param uri The Uri to check.
|
||||||
|
* @return Whether the Uri authority is MediaProvider.
|
||||||
|
*/
|
||||||
|
private fun isMediaDocument(uri: Uri): Boolean {
|
||||||
|
return "com.android.providers.media.documents" == uri.authority
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param uri The Uri to check.
|
||||||
|
* @return Whether the Uri authority is Google Photos.
|
||||||
|
*/
|
||||||
|
private fun isGooglePhotosUri(uri: Uri): Boolean {
|
||||||
|
return "com.google.android.apps.photos.content" == uri.authority
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package com.geode.launcher.utils
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.ApplicationInfo
|
||||||
|
import android.content.pm.PackageInfo
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.content.res.AssetManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import okio.ByteString.Companion.toByteString
|
||||||
|
|
||||||
|
object GamePackageUtils {
|
||||||
|
fun isGameInstalled(packageManager: PackageManager): Boolean {
|
||||||
|
return try {
|
||||||
|
packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0)
|
||||||
|
true
|
||||||
|
} catch (e: PackageManager.NameNotFoundException) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showDownloadBadge(packageManager: PackageManager) =
|
||||||
|
!isGameInstalled(packageManager) || !identifyGameLegitimacy(packageManager)
|
||||||
|
|
||||||
|
fun getGameVersionCode(packageManager: PackageManager): Long {
|
||||||
|
val game = packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0)
|
||||||
|
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
game.longVersionCode
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
game.versionCode.toLong()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getGameVersionCodeOrNull(packageManager: PackageManager): Long? {
|
||||||
|
return try {
|
||||||
|
getGameVersionCode(packageManager)
|
||||||
|
} catch (e: PackageManager.NameNotFoundException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getGameVersionString(packageManager: PackageManager): String {
|
||||||
|
val game = packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0)
|
||||||
|
return game.versionName ?: "0.000"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val gameVersionMap = mapOf(
|
||||||
|
37L to "2.200",
|
||||||
|
38L to "2.205",
|
||||||
|
39L to "2.206",
|
||||||
|
40L to "2.2074", // ok
|
||||||
|
41L to "2.2081",
|
||||||
|
42L to "2.209",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the version name that more closely aligns to the Windows/macOS releases
|
||||||
|
*/
|
||||||
|
fun getUnifiedVersionName(packageManager: PackageManager): String {
|
||||||
|
val versionCode = getGameVersionCode(packageManager)
|
||||||
|
return gameVersionMap[versionCode] ?: getGameVersionString(packageManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSupportedUnifiedVersion(): String {
|
||||||
|
return gameVersionMap[Constants.SUPPORTED_VERSION_CODE] ?: Constants.SUPPORTED_VERSION_STRING
|
||||||
|
}
|
||||||
|
|
||||||
|
fun detectAbiMismatch(context: Context, packageInfo: PackageInfo, loadException: Error): Boolean {
|
||||||
|
val abi = LaunchUtils.applicationArchitecture
|
||||||
|
val isExtracted =
|
||||||
|
packageInfo.applicationInfo!!.flags and ApplicationInfo.FLAG_EXTRACT_NATIVE_LIBS == ApplicationInfo.FLAG_EXTRACT_NATIVE_LIBS
|
||||||
|
val isSplit = (packageInfo.applicationInfo?.splitSourceDirs?.size ?: 0) > 1
|
||||||
|
|
||||||
|
val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
packageInfo.longVersionCode
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
packageInfo.versionCode.toLong()
|
||||||
|
}
|
||||||
|
|
||||||
|
val metadata =
|
||||||
|
"Geometry Dash metadata:\nSplit sources: $isSplit\nExtracted libraries: $isExtracted\nLauncher architecture: $abi\nGame version: $versionCode"
|
||||||
|
Log.i("GeodeLauncher", metadata)
|
||||||
|
|
||||||
|
// easiest check! these messages are hardcoded in bionic/linker/linker_phdr.cpp
|
||||||
|
if (loadException.message?.contains("32-bit instead of 64-bit") == true) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadException.message?.contains("64-bit instead of 32-bit") == true) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// determine if it can find gd libraries for the opposite architecture
|
||||||
|
val gdBinaryName = "lib${Constants.COCOS_LIB_NAME}.so"
|
||||||
|
val oppositeArchitecture = if (abi == "arm64-v8a") "armeabi-v7a" else "arm64-v8a"
|
||||||
|
|
||||||
|
// detect issues using native library directory (only works on extracted libraries)
|
||||||
|
val nativeLibraryDir = packageInfo.applicationInfo!!.nativeLibraryDir
|
||||||
|
|
||||||
|
if (nativeLibraryDir.contains(oppositeArchitecture)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// android has some odd behavior where 32bit libraries get installed to the arm directory instead of armeabi-v7a
|
||||||
|
// detect it, ig
|
||||||
|
if (oppositeArchitecture == "armeabi-v7a" && nativeLibraryDir.contains("/lib/arm/")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isExtracted) {
|
||||||
|
try {
|
||||||
|
context.assets.openNonAssetFd("lib/$oppositeArchitecture/$gdBinaryName")
|
||||||
|
return true
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// this is good, actually!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("DiscouragedPrivateApi")
|
||||||
|
fun addAssetsFromPackage(assetManager: AssetManager, packageInfo: PackageInfo) {
|
||||||
|
// this method is officially marked as deprecated but it is the only method we are allowed to reflect
|
||||||
|
// (the source recommends replacing with AssetManager.setApkAssets(ApkAssets[], boolean) lol)
|
||||||
|
val clazz = assetManager.javaClass
|
||||||
|
val aspMethod = clazz.getDeclaredMethod("addAssetPath", String::class.java)
|
||||||
|
|
||||||
|
aspMethod.invoke(assetManager, packageInfo.applicationInfo?.sourceDir)
|
||||||
|
packageInfo.applicationInfo?.splitSourceDirs?.forEach {
|
||||||
|
aspMethod.invoke(assetManager, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val GAME_CERTIFICATE_HASH = "f5e7d8284d72c461a5f022d0cf755df101c3bb1e69cbe241bc1aef2cc5610a43"
|
||||||
|
private const val AMAZON_CERTIFICATE_HASH = "1f228081e4d66e006887d4ba0f3d40e96a80c758b84231f11cd5c1c9aef6048f"
|
||||||
|
|
||||||
|
private fun validateCertificate(certificate: ByteArray): GameSource = when (val hash = certificate.toByteString().sha256().hex()) {
|
||||||
|
GAME_CERTIFICATE_HASH -> GameSource.GOOGLE
|
||||||
|
AMAZON_CERTIFICATE_HASH -> GameSource.AMAZON
|
||||||
|
else -> {
|
||||||
|
println("Encountered certificate hash: $hash")
|
||||||
|
GameSource.UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class GameSource {
|
||||||
|
UNKNOWN, GOOGLE, AMAZON
|
||||||
|
}
|
||||||
|
|
||||||
|
fun identifyGameSource(packageManager: PackageManager): GameSource {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val certificates = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
val game = packageManager.getPackageInfo(Constants.PACKAGE_NAME, PackageManager.GET_SIGNING_CERTIFICATES)
|
||||||
|
val signingInfo = game.signingInfo
|
||||||
|
|
||||||
|
signingInfo?.signingCertificateHistory ?: return GameSource.UNKNOWN
|
||||||
|
} else {
|
||||||
|
val game = packageManager.getPackageInfo(Constants.PACKAGE_NAME, PackageManager.GET_SIGNATURES)
|
||||||
|
game.signatures
|
||||||
|
}
|
||||||
|
|
||||||
|
return certificates?.map {
|
||||||
|
validateCertificate(it.toByteArray())
|
||||||
|
}?.firstOrNull {
|
||||||
|
it != GameSource.UNKNOWN
|
||||||
|
} ?: GameSource.UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
fun identifyGameLegitimacy(packageManager: PackageManager) =
|
||||||
|
identifyGameSource(packageManager) != GameSource.UNKNOWN
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,118 @@
|
|||||||
|
package com.geode.launcher.utils
|
||||||
|
|
||||||
|
import android.graphics.drawable.AdaptiveIconDrawable
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||||
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalResources
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.core.content.res.ResourcesCompat
|
||||||
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
|
import com.geode.launcher.R
|
||||||
|
|
||||||
|
// https://gist.github.com/tkuenneth/ddf598663f041dc79960cda503d14448?permalink_comment_id=4660486#gistcomment-4660486
|
||||||
|
@Composable
|
||||||
|
fun adaptiveIconPainterResource(@DrawableRes id: Int): Painter {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val res = LocalResources.current
|
||||||
|
val theme = context.theme
|
||||||
|
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val adaptiveIcon = remember(id) {
|
||||||
|
ResourcesCompat.getDrawable(res, id, theme) as? AdaptiveIconDrawable
|
||||||
|
}
|
||||||
|
if (adaptiveIcon != null) {
|
||||||
|
remember(id) {
|
||||||
|
BitmapPainter(adaptiveIcon.toBitmap().asImageBitmap())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
painterResource(id)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
painterResource(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ApplicationIcon {
|
||||||
|
DEFAULT,
|
||||||
|
GEODE,
|
||||||
|
PRIDE,
|
||||||
|
TRANS,
|
||||||
|
GREEN,
|
||||||
|
SAPPHIRE;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromId(id: String) = when (id) {
|
||||||
|
"default" -> DEFAULT
|
||||||
|
"geode" -> GEODE
|
||||||
|
"pride" -> PRIDE
|
||||||
|
"trans" -> TRANS
|
||||||
|
"sapphire" -> SAPPHIRE
|
||||||
|
"green" -> GREEN
|
||||||
|
else -> DEFAULT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toId() = when (this) {
|
||||||
|
PRIDE -> "pride"
|
||||||
|
GEODE -> "geode"
|
||||||
|
DEFAULT -> "default"
|
||||||
|
TRANS -> "trans"
|
||||||
|
SAPPHIRE -> "sapphire"
|
||||||
|
GREEN -> "green"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ApplicationIconDetails(
|
||||||
|
val id: String,
|
||||||
|
val component: String,
|
||||||
|
@param:DrawableRes val iconId: Int,
|
||||||
|
@param:StringRes val nameId: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
object IconUtils {
|
||||||
|
fun getIconDetails(id: ApplicationIcon) = when (id) {
|
||||||
|
ApplicationIcon.DEFAULT -> ApplicationIconDetails(
|
||||||
|
id = id.toId(),
|
||||||
|
component = "MainActivity",
|
||||||
|
iconId = R.mipmap.ic_launcher,
|
||||||
|
nameId = R.string.application_icon_default
|
||||||
|
)
|
||||||
|
ApplicationIcon.GEODE -> ApplicationIconDetails(
|
||||||
|
id = id.toId(),
|
||||||
|
component = "MainActivityGeode",
|
||||||
|
iconId = R.mipmap.ic_launcher_geode,
|
||||||
|
nameId = R.string.application_icon_geode
|
||||||
|
)
|
||||||
|
ApplicationIcon.PRIDE -> ApplicationIconDetails(
|
||||||
|
id = id.toId(),
|
||||||
|
component = "MainActivityPride",
|
||||||
|
iconId = R.mipmap.ic_launcher_pride,
|
||||||
|
nameId = R.string.application_icon_pride
|
||||||
|
)
|
||||||
|
ApplicationIcon.TRANS -> ApplicationIconDetails(
|
||||||
|
id = id.toId(),
|
||||||
|
component = "MainActivityTrans",
|
||||||
|
iconId = R.mipmap.ic_launcher_trans,
|
||||||
|
nameId = R.string.application_icon_trans
|
||||||
|
)
|
||||||
|
ApplicationIcon.SAPPHIRE -> ApplicationIconDetails(
|
||||||
|
id = id.toId(),
|
||||||
|
component = "MainActivitySapphire",
|
||||||
|
iconId = R.mipmap.ic_launcher_sapphire,
|
||||||
|
nameId = R.string.application_icon_sapphire,
|
||||||
|
)
|
||||||
|
ApplicationIcon.GREEN -> ApplicationIconDetails(
|
||||||
|
id = id.toId(),
|
||||||
|
component = "MainActivityGreen",
|
||||||
|
iconId = R.mipmap.ic_launcher_green,
|
||||||
|
nameId = R.string.application_icon_green,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,46 +1,20 @@
|
|||||||
package com.geode.launcher.utils
|
package com.geode.launcher.utils
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.pm.PackageInfo
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.content.res.AssetManager
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import com.geode.launcher.BuildConfig
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.io.Serializable
|
||||||
|
|
||||||
object LaunchUtils {
|
object LaunchUtils {
|
||||||
fun isGeometryDashInstalled(packageManager: PackageManager): Boolean {
|
|
||||||
return try {
|
|
||||||
packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0)
|
|
||||||
true
|
|
||||||
} catch (e: PackageManager.NameNotFoundException) {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getGeometryDashVersionCode(packageManager: PackageManager): Long {
|
|
||||||
val game = packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0)
|
|
||||||
|
|
||||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
||||||
game.longVersionCode
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
game.versionCode.toLong()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getGeometryDashVersionString(packageManager: PackageManager): String {
|
|
||||||
val game = packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0)
|
|
||||||
return game.versionName
|
|
||||||
}
|
|
||||||
|
|
||||||
// supposedly CPU_ABI returns the current arch for the running application
|
// supposedly CPU_ABI returns the current arch for the running application
|
||||||
// despite being deprecated, this is also one of the few ways to get this information
|
// despite being deprecated, this is also one of the few ways to get this information
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
val applicationArchitecture: String = Build.CPU_ABI
|
val applicationArchitecture: String = Build.CPU_ABI
|
||||||
|
|
||||||
val platformName: String = if (applicationArchitecture == "arm64-v8a")
|
val is64bit = applicationArchitecture == "arm64-v8a"
|
||||||
"android64" else "android32"
|
|
||||||
|
val platformName: String = if (is64bit) "android64" else "android32"
|
||||||
|
|
||||||
val geodeFilename: String = "Geode.$platformName.so"
|
val geodeFilename: String = "Geode.$platformName.so"
|
||||||
|
|
||||||
@@ -68,23 +42,38 @@ object LaunchUtils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun isGeodeInstalled(context: Context): Boolean {
|
fun isGeodeInstalled(context: Context): Boolean {
|
||||||
|
@Suppress("SENSELESS_COMPARISON")
|
||||||
|
if (BuildConfig.PREBUNDLED_GEODE != null) return true
|
||||||
|
|
||||||
return getInstalledGeodePath(context) != null
|
return getInstalledGeodePath(context) != null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the directory that Geode/the game should base itself off of.
|
* Returns the directory that Geode/the game should base itself off of.
|
||||||
*/
|
*/
|
||||||
fun getBaseDirectory(context: Context): File {
|
fun getBaseDirectory(context: Context, ignoreProfile: Boolean = false): File {
|
||||||
// deprecated, but seems to be the best choice of directory (i forced mat to test it)
|
val dir = if (BuildConfig.GOOGLE_PLAY_BUILD) {
|
||||||
// also, is getting the first item the correct choice here?? what do they mean
|
context.getExternalFilesDir(null)!!.parentFile!!
|
||||||
@Suppress("DEPRECATION")
|
} else {
|
||||||
val dir = context.externalMediaDirs.first()
|
// deprecated, but seems to be the best choice of directory (i forced mat to test it)
|
||||||
|
// also, is getting the first item the correct choice here?? what do they mean
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
context.externalMediaDirs.first()
|
||||||
|
}
|
||||||
|
|
||||||
// prevent having resources added to system gallery
|
// prevent having resources added to system gallery
|
||||||
// accessing this file every time the directory is read may be a little wasteful...
|
// accessing this file every time the directory is read may be a little wasteful...
|
||||||
val noMediaPath = File(dir, ".nomedia")
|
val noMediaPath = File(dir, ".nomedia")
|
||||||
noMediaPath.createNewFile()
|
noMediaPath.createNewFile()
|
||||||
|
|
||||||
|
val currentProfile = ProfileManager.get(context).getCurrentProfile()
|
||||||
|
if (currentProfile != null && !ignoreProfile) {
|
||||||
|
val profile = File(dir, "profiles/$currentProfile/")
|
||||||
|
profile.mkdirs()
|
||||||
|
|
||||||
|
return profile
|
||||||
|
}
|
||||||
|
|
||||||
return dir
|
return dir
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,16 +81,58 @@ object LaunchUtils {
|
|||||||
return File(getBaseDirectory(context), "save")
|
return File(getBaseDirectory(context), "save")
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("DiscouragedPrivateApi")
|
val CRASH_INDICATOR_NAMES = setOf("lastSessionDidCrash", "last-pid", "last-crashed")
|
||||||
fun addAssetsFromPackage(assetManager: AssetManager, packageInfo: PackageInfo) {
|
|
||||||
// this method is officially marked as deprecated but it is the only method we are allowed to reflect
|
|
||||||
// (the source recommends replacing with AssetManager.setApkAssets(ApkAssets[], boolean) lol)
|
|
||||||
val clazz = assetManager.javaClass
|
|
||||||
val aspMethod = clazz.getDeclaredMethod("addAssetPath", String::class.java)
|
|
||||||
|
|
||||||
aspMethod.invoke(assetManager, packageInfo.applicationInfo.sourceDir)
|
fun getCrashDirectory(context: Context): File {
|
||||||
packageInfo.applicationInfo.splitSourceDirs?.forEach {
|
val base = getBaseDirectory(context)
|
||||||
aspMethod.invoke(assetManager, it)
|
return File(base, "game/geode/crashlogs/")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getGeodeLogsDirectory(context: Context): File {
|
||||||
|
val base = getBaseDirectory(context)
|
||||||
|
return File(base, "game/geode/logs/")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getGeodeResourcesDirectory(context: Context): File {
|
||||||
|
val base = getBaseDirectory(context)
|
||||||
|
return File(base, "game/geode/resources/geode.loader/")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getLastCrash(context: Context): File? {
|
||||||
|
val crashDirectory = getCrashDirectory(context)
|
||||||
|
if (!crashDirectory.exists()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val children = crashDirectory.listFiles {
|
||||||
|
// ignore indicator files (including old file)
|
||||||
|
_, name -> !CRASH_INDICATOR_NAMES.contains(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return children?.maxByOrNull { it.lastModified() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun lastSessionCrashed(context: Context): Boolean {
|
||||||
|
val base = getCrashDirectory(context)
|
||||||
|
return CRASH_INDICATOR_NAMES.any {
|
||||||
|
File(base, it).exists()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum class LauncherError : Serializable {
|
||||||
|
LINKER_NEEDS_64BIT,
|
||||||
|
LINKER_NEEDS_32BIT,
|
||||||
|
LINKER_FAILED_STL,
|
||||||
|
LINKER_FAILED,
|
||||||
|
GENERIC,
|
||||||
|
CRASHED;
|
||||||
|
|
||||||
|
fun isAbiFailure(): Boolean {
|
||||||
|
return this == LINKER_NEEDS_32BIT || this == LINKER_NEEDS_64BIT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const val LAUNCHER_KEY_RETURN_ERROR = "return_error"
|
||||||
|
const val LAUNCHER_KEY_RETURN_MESSAGE = "return_message"
|
||||||
|
const val LAUNCHER_KEY_RETURN_EXTENDED_MESSAGE = "return_extended_message"
|
||||||
}
|
}
|
||||||
@@ -8,51 +8,86 @@ import androidx.compose.runtime.MutableState
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import com.geode.launcher.BuildConfig
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extension object for SharedPreferences to add better key safety and default values.
|
* Extension object for SharedPreferences to add better key safety and default values.
|
||||||
*/
|
*/
|
||||||
class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
|
class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
|
||||||
companion object {
|
companion object {
|
||||||
private const val FILE_KEY = "GeodeLauncherPreferencesFileKey"
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun useBooleanPreference(preferenceKey: Key, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current): MutableState<Boolean> {
|
fun useBooleanPreference(preferenceKey: Key, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current): MutableState<Boolean> {
|
||||||
return usePreference(preferenceKey, lifecycleOwner) { p, k ->
|
return usePreference(
|
||||||
p.getBoolean(k)
|
preferenceKey,
|
||||||
}
|
lifecycleOwner,
|
||||||
|
preferenceGet = { p, k -> p.getBoolean(k) },
|
||||||
|
preferenceSet = { p, k, v -> p.setBoolean(k, v) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun useStringPreference(preferenceKey: Key, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current): MutableState<String?> {
|
fun useStringPreference(preferenceKey: Key, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current): MutableState<String?> {
|
||||||
return usePreference(preferenceKey, lifecycleOwner) { p, k ->
|
return usePreference(
|
||||||
p.getString(k)
|
preferenceKey,
|
||||||
}
|
lifecycleOwner,
|
||||||
|
preferenceGet = { p, k -> p.getString(k) },
|
||||||
|
preferenceSet = { p, k, v -> p.setString(k, v) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun useIntPreference(preferenceKey: Key, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current): MutableState<Int> {
|
fun useIntPreference(preferenceKey: Key, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current): MutableState<Int> {
|
||||||
return usePreference(preferenceKey, lifecycleOwner) { p, k ->
|
return usePreference(
|
||||||
p.getInt(k)
|
preferenceKey,
|
||||||
}
|
lifecycleOwner,
|
||||||
|
preferenceGet = { p, k -> p.getInt(k) },
|
||||||
|
preferenceSet = { p, k, v -> p.setInt(k, v) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun <T> usePreference(preferenceKey: Key, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, preferenceGet: (PreferenceUtils, Key) -> T): MutableState<T> {
|
fun useLongPreference(preferenceKey: Key, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current): MutableState<Long> {
|
||||||
|
return usePreference(
|
||||||
|
preferenceKey,
|
||||||
|
lifecycleOwner,
|
||||||
|
preferenceGet = { p, k -> p.getLong(k) },
|
||||||
|
preferenceSet = { p, k, v -> p.setLong(k, v) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun <T> usePreference(
|
||||||
|
preferenceKey: Key,
|
||||||
|
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
|
||||||
|
preferenceGet: (PreferenceUtils, Key) -> T,
|
||||||
|
preferenceSet: (PreferenceUtils, Key, T) -> Unit
|
||||||
|
): MutableState<T> {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val sharedPreferences = context.getSharedPreferences(FILE_KEY, Context.MODE_PRIVATE)
|
|
||||||
|
val currentProfile = remember { ProfileManager.get(context).getCurrentFileKey() }
|
||||||
|
val sharedPreferences = context.getSharedPreferences(currentProfile, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
val preferences = get(sharedPreferences)
|
val preferences = get(sharedPreferences)
|
||||||
|
|
||||||
val preferenceValue = remember {
|
// inferring the type causes an internal compiler error. lol
|
||||||
mutableStateOf(
|
val preferenceValue: MutableState<T> = remember {
|
||||||
preferenceGet(preferences, preferenceKey)
|
val state = mutableStateOf(preferenceGet(preferences, preferenceKey))
|
||||||
)
|
object : MutableState<T> by state {
|
||||||
|
override var value: T
|
||||||
|
get() = state.value
|
||||||
|
set(value) {
|
||||||
|
// ignore setting the same thing (messes up default preferences)
|
||||||
|
if (state.value == value) { return }
|
||||||
|
state.value = value
|
||||||
|
|
||||||
|
preferenceSet(preferences, preferenceKey, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val listener = SharedPreferences.OnSharedPreferenceChangeListener {
|
val listener = SharedPreferences.OnSharedPreferenceChangeListener {
|
||||||
@@ -79,7 +114,8 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun get(context: Context): PreferenceUtils {
|
fun get(context: Context): PreferenceUtils {
|
||||||
val sharedPreferences = context.getSharedPreferences(FILE_KEY, Context.MODE_PRIVATE)
|
val currentFileKey = ProfileManager.get(context).getCurrentFileKey()
|
||||||
|
val sharedPreferences = context.getSharedPreferences(currentFileKey, Context.MODE_PRIVATE)
|
||||||
return get(sharedPreferences)
|
return get(sharedPreferences)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,35 +125,87 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum class Key {
|
enum class Key {
|
||||||
LOAD_TESTING,
|
|
||||||
LOAD_AUTOMATICALLY,
|
LOAD_AUTOMATICALLY,
|
||||||
UPDATE_AUTOMATICALLY,
|
UPDATE_AUTOMATICALLY,
|
||||||
RELEASE_CHANNEL,
|
|
||||||
CURRENT_VERSION_TAG,
|
CURRENT_VERSION_TAG,
|
||||||
CURRENT_VERSION_TIMESTAMP,
|
CURRENT_VERSION_TIMESTAMP,
|
||||||
THEME,
|
THEME,
|
||||||
BLACK_BACKGROUND,
|
BLACK_BACKGROUND,
|
||||||
CURRENT_RELEASE_MODIFIED,
|
CURRENT_RELEASE_MODIFIED,
|
||||||
|
DISMISSED_GJ_UPDATE,
|
||||||
|
LAUNCH_ARGUMENTS,
|
||||||
|
LIMIT_ASPECT_RATIO,
|
||||||
|
DISPLAY_MODE,
|
||||||
|
RELEASE_CHANNEL_TAG,
|
||||||
|
DEVELOPER_MODE,
|
||||||
|
CLEANUP_APKS,
|
||||||
|
CUSTOM_ASPECT_RATIO,
|
||||||
|
SCREEN_ZOOM,
|
||||||
|
SCREEN_ZOOM_FIT,
|
||||||
|
LIMIT_FRAME_RATE,
|
||||||
|
DISABLE_USER_THEME,
|
||||||
|
SELECTED_ICON,
|
||||||
|
WAIT_PERIOD,
|
||||||
|
LAST_UPDATE_CHECK_TIME,
|
||||||
|
LAST_LAUNCHER_UPDATE,
|
||||||
|
DISABLE_UPDATE_CACHE,
|
||||||
|
PREBUNDLED_GEODE_MIGRATION_PERFORMED,
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun defaultValueForBooleanKey(key: Key): Boolean {
|
private fun defaultValueForBooleanKey(key: Key): Boolean {
|
||||||
return when (key) {
|
return when (key) {
|
||||||
Key.UPDATE_AUTOMATICALLY, Key.RELEASE_CHANNEL -> true
|
Key.UPDATE_AUTOMATICALLY -> true
|
||||||
|
Key.DEVELOPER_MODE -> BuildConfig.DEBUG
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun defaultValueForIntKey(key: Key) = when (key) {
|
||||||
|
Key.DISPLAY_MODE -> if (this.getBoolean(Key.LIMIT_ASPECT_RATIO)) 1 else 0
|
||||||
|
Key.SCREEN_ZOOM -> 100
|
||||||
|
Key.WAIT_PERIOD -> 3
|
||||||
|
// people wanted a reset on nightly anyways, so this is a good excuse to do so
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun defaultValueForStringKey(key: Key) = when (key) {
|
||||||
|
Key.CUSTOM_ASPECT_RATIO -> "16_9"
|
||||||
|
Key.SELECTED_ICON -> "default"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
private fun keyToName(key: Key): String {
|
private fun keyToName(key: Key): String {
|
||||||
return when (key) {
|
return when (key) {
|
||||||
Key.LOAD_TESTING -> "PreferenceLoadTesting"
|
|
||||||
Key.LOAD_AUTOMATICALLY -> "PreferenceLoadAutomatically"
|
Key.LOAD_AUTOMATICALLY -> "PreferenceLoadAutomatically"
|
||||||
Key.UPDATE_AUTOMATICALLY -> "PreferenceUpdateAutomatically"
|
Key.UPDATE_AUTOMATICALLY -> "PreferenceUpdateAutomatically"
|
||||||
Key.RELEASE_CHANNEL -> "PreferenceReleaseChannel"
|
|
||||||
Key.CURRENT_VERSION_TAG -> "PreferenceCurrentVersionName"
|
Key.CURRENT_VERSION_TAG -> "PreferenceCurrentVersionName"
|
||||||
Key.CURRENT_VERSION_TIMESTAMP -> "PreferenceCurrentVersionDescriptor"
|
Key.CURRENT_VERSION_TIMESTAMP -> "PreferenceCurrentVersionDescriptor"
|
||||||
Key.THEME -> "PreferenceTheme"
|
Key.THEME -> "PreferenceTheme"
|
||||||
Key.BLACK_BACKGROUND -> "PreferenceBlackBackground"
|
Key.BLACK_BACKGROUND -> "PreferenceBlackBackground"
|
||||||
Key.CURRENT_RELEASE_MODIFIED -> "PreferenceReleaseModified"
|
Key.CURRENT_RELEASE_MODIFIED -> "PreferenceReleaseModifiedHash"
|
||||||
|
Key.DISMISSED_GJ_UPDATE -> "PreferenceDismissedGJUpdate"
|
||||||
|
Key.LAUNCH_ARGUMENTS -> "PreferenceLaunchArguments"
|
||||||
|
Key.LIMIT_ASPECT_RATIO -> "PreferenceLimitAspectRatio"
|
||||||
|
Key.DISPLAY_MODE -> "PreferenceDisplayMode"
|
||||||
|
Key.RELEASE_CHANNEL_TAG -> "PreferenceReleaseChannelTag"
|
||||||
|
Key.DEVELOPER_MODE -> "PreferenceDeveloperMode"
|
||||||
|
Key.CLEANUP_APKS -> "PreferenceCleanupPackages"
|
||||||
|
Key.CUSTOM_ASPECT_RATIO -> "PreferenceCustomAspectRatio"
|
||||||
|
Key.SCREEN_ZOOM -> "PreferenceScreenZoom"
|
||||||
|
Key.SCREEN_ZOOM_FIT -> "PreferenceScreenZoomFit"
|
||||||
|
Key.LIMIT_FRAME_RATE -> "PreferenceLimitFrameRate"
|
||||||
|
Key.DISABLE_USER_THEME -> "PreferenceDisableUserTheme"
|
||||||
|
Key.SELECTED_ICON -> "PreferenceSelectedIcon"
|
||||||
|
Key.WAIT_PERIOD -> "PreferenceWaitPeriod"
|
||||||
|
Key.LAST_UPDATE_CHECK_TIME -> "PreferenceLastUpdateCheckTime"
|
||||||
|
Key.LAST_LAUNCHER_UPDATE -> "PreferenceLastLauncherUpdate"
|
||||||
|
Key.DISABLE_UPDATE_CACHE -> "PreferenceDisableUpdateCache"
|
||||||
|
Key.PREBUNDLED_GEODE_MIGRATION_PERFORMED -> "PreferencePrebundledGeodeMigrationPerformed"
|
||||||
|
// Key.RELEASE_CHANNEL -> "PreferenceReleaseChannel"
|
||||||
|
// Key.LAST_DISMISSED_UPDATE -> "PreferenceLastDismissedUpdate"
|
||||||
|
// Key.FORCE_HRR -> "PreferenceForceHighRefreshRate"
|
||||||
|
// Key.DISABLE_REDESIGN -> "PreferenceDisableRedesign"
|
||||||
|
// Key.CUSTOM_SYMBOL_LIST -> "PreferenceCustomSymbolList"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +216,13 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
|
|||||||
return sharedPreferences.getBoolean(keyName, defaultValue)
|
return sharedPreferences.getBoolean(keyName, defaultValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setBoolean(key: Key, value: Boolean) {
|
||||||
|
val keyName = keyToName(key)
|
||||||
|
sharedPreferences.edit {
|
||||||
|
putBoolean(keyName, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun toggleBoolean(key: Key): Boolean {
|
fun toggleBoolean(key: Key): Boolean {
|
||||||
val currentValue = getBoolean(key)
|
val currentValue = getBoolean(key)
|
||||||
val keyName = keyToName(key)
|
val keyName = keyToName(key)
|
||||||
@@ -141,10 +236,10 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
|
|||||||
|
|
||||||
fun getString(key: Key): String? {
|
fun getString(key: Key): String? {
|
||||||
val keyName = keyToName(key)
|
val keyName = keyToName(key)
|
||||||
return sharedPreferences.getString(keyName, null)
|
return sharedPreferences.getString(keyName, defaultValueForStringKey(key))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setString(key: Key, value: String) {
|
fun setString(key: Key, value: String?) {
|
||||||
val keyName = keyToName(key)
|
val keyName = keyToName(key)
|
||||||
sharedPreferences.edit {
|
sharedPreferences.edit {
|
||||||
putString(keyName, value)
|
putString(keyName, value)
|
||||||
@@ -165,7 +260,7 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
|
|||||||
|
|
||||||
fun getInt(key: Key): Int {
|
fun getInt(key: Key): Int {
|
||||||
val keyName = keyToName(key)
|
val keyName = keyToName(key)
|
||||||
return sharedPreferences.getInt(keyName, 0)
|
return sharedPreferences.getInt(keyName, defaultValueForIntKey(key))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setInt(key: Key, value: Int) {
|
fun setInt(key: Key, value: Int) {
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package com.geode.launcher.utils
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
private const val FILE_KEY = "GeodeLauncherPreferencesFileKey"
|
||||||
|
private const val PROFILES_FILE_KEY = "GeodeLauncherProfiles"
|
||||||
|
|
||||||
|
private const val PROFILE_LIST_SAVE_KEY = "profiles"
|
||||||
|
private const val PROFILE_CURRENT_SAVE_KEY = "currentProfile"
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Profile(val path: String, val name: String)
|
||||||
|
|
||||||
|
class ProfileManager {
|
||||||
|
private val applicationContext: Context
|
||||||
|
private val preferences: SharedPreferences
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private lateinit var managerInstance: ProfileManager
|
||||||
|
|
||||||
|
fun get(context: Context): ProfileManager {
|
||||||
|
if (!Companion::managerInstance.isInitialized) {
|
||||||
|
val applicationContext = context.applicationContext
|
||||||
|
|
||||||
|
managerInstance = ProfileManager(applicationContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
return managerInstance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(applicationContext: Context) {
|
||||||
|
this.applicationContext = applicationContext
|
||||||
|
this.preferences = applicationContext.getSharedPreferences(PROFILES_FILE_KEY, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
_storedProfiles.value = getProfiles()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _storedProfiles = MutableStateFlow<List<Profile>>(emptyList())
|
||||||
|
val storedProfiles = _storedProfiles.asStateFlow()
|
||||||
|
|
||||||
|
fun getCurrentProfile(): String? {
|
||||||
|
return preferences.getString(PROFILE_CURRENT_SAVE_KEY, null)
|
||||||
|
.takeUnless { it.isNullOrEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCurrentFileKey(): String {
|
||||||
|
val currentProfile = getCurrentProfile()
|
||||||
|
?: return FILE_KEY
|
||||||
|
|
||||||
|
return "${FILE_KEY}_${currentProfile}"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getProfile(path: String): Profile? {
|
||||||
|
val profilesSet = preferences.getStringSet(PROFILE_LIST_SAVE_KEY, null)
|
||||||
|
return profilesSet?.firstNotNullOfOrNull {
|
||||||
|
Json.decodeFromString<Profile>(it).takeIf { it.path == path }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a profile into the save, removing any duplicate profiles beforehand
|
||||||
|
*/
|
||||||
|
fun storeProfile(profile: Profile) {
|
||||||
|
val profilesSet = preferences.getStringSet(PROFILE_LIST_SAVE_KEY, null) ?: emptySet<String>()
|
||||||
|
|
||||||
|
val removedList = profilesSet.filterTo(LinkedHashSet()) {
|
||||||
|
val saveProfile = Json.decodeFromString<Profile>(it)
|
||||||
|
saveProfile.path != profile.path
|
||||||
|
}
|
||||||
|
removedList.add(Json.encodeToString(profile))
|
||||||
|
|
||||||
|
preferences.edit {
|
||||||
|
putStringSet(PROFILE_LIST_SAVE_KEY, removedList)
|
||||||
|
}
|
||||||
|
|
||||||
|
_storedProfiles.value = getProfiles()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteProfile(path: String) {
|
||||||
|
val profilesSet = preferences.getStringSet(PROFILE_LIST_SAVE_KEY, null) ?: emptySet<String>()
|
||||||
|
|
||||||
|
val removedList = profilesSet.filterTo(LinkedHashSet()) {
|
||||||
|
val saveProfile = Json.decodeFromString<Profile>(it)
|
||||||
|
saveProfile.path != path
|
||||||
|
}
|
||||||
|
|
||||||
|
preferences.edit {
|
||||||
|
putStringSet(PROFILE_LIST_SAVE_KEY, removedList)
|
||||||
|
}
|
||||||
|
|
||||||
|
_storedProfiles.value = getProfiles()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteProfiles(paths: List<String>) {
|
||||||
|
val profilesSet = preferences.getStringSet(PROFILE_LIST_SAVE_KEY, null) ?: emptySet<String>()
|
||||||
|
|
||||||
|
val removedList = profilesSet.filterTo(LinkedHashSet()) {
|
||||||
|
val saveProfile = Json.decodeFromString<Profile>(it)
|
||||||
|
!paths.contains(saveProfile.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
preferences.edit(commit = true) {
|
||||||
|
putStringSet(PROFILE_LIST_SAVE_KEY, removedList)
|
||||||
|
}
|
||||||
|
|
||||||
|
_storedProfiles.value = getProfiles()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCurrentProfile(profile: String?) {
|
||||||
|
preferences.edit(commit = true) {
|
||||||
|
if (profile.isNullOrEmpty()) {
|
||||||
|
remove(PROFILE_CURRENT_SAVE_KEY)
|
||||||
|
} else {
|
||||||
|
putString(PROFILE_CURRENT_SAVE_KEY, profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getProfiles(): List<Profile> {
|
||||||
|
val profilesSet = preferences.getStringSet(PROFILE_LIST_SAVE_KEY, null) ?: emptySet<String>()
|
||||||
|
|
||||||
|
return profilesSet.map {
|
||||||
|
Json.decodeFromString<Profile>(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
package com.geode.launcher.utils
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.util.Log
|
|
||||||
import com.geode.launcher.api.Release
|
|
||||||
import com.geode.launcher.api.ReleaseRepository
|
|
||||||
import kotlinx.coroutines.CancellationException
|
|
||||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
|
||||||
import kotlinx.coroutines.GlobalScope
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.cancelAndJoin
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import okhttp3.Cache
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import java.io.File
|
|
||||||
import java.io.InterruptedIOException
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Singleton to manage Geode updates, from update checking to downloading.
|
|
||||||
*/
|
|
||||||
class ReleaseManager private constructor(
|
|
||||||
private val applicationContext: Context,
|
|
||||||
private val httpClient: OkHttpClient,
|
|
||||||
private val releaseRepository: ReleaseRepository = ReleaseRepository(httpClient)
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
private lateinit var managerInstance: ReleaseManager
|
|
||||||
|
|
||||||
fun get(context: Context): ReleaseManager {
|
|
||||||
if (!::managerInstance.isInitialized) {
|
|
||||||
val applicationContext = context.applicationContext
|
|
||||||
|
|
||||||
val httpClient = OkHttpClient.Builder()
|
|
||||||
.cache(Cache(
|
|
||||||
applicationContext.cacheDir,
|
|
||||||
maxSize = 10L * 1024L * 1024L // 10mb cache size
|
|
||||||
))
|
|
||||||
.build()
|
|
||||||
|
|
||||||
managerInstance = ReleaseManager(
|
|
||||||
applicationContext,
|
|
||||||
httpClient
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return managerInstance
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sealed class ReleaseManagerState {
|
|
||||||
data object InUpdateCheck : ReleaseManagerState()
|
|
||||||
data class Failure(val exception: Exception) : ReleaseManagerState()
|
|
||||||
data class InDownload(val downloaded: Long, val outOf: Long) : ReleaseManagerState()
|
|
||||||
data class Finished(val hasUpdated: Boolean = false) : ReleaseManagerState()
|
|
||||||
}
|
|
||||||
|
|
||||||
class UpdateException(reason: Reason? = null, cause: Throwable? = null) : Exception(reason?.name, cause) {
|
|
||||||
enum class Reason {
|
|
||||||
EXTERNAL_FILE_IN_USE
|
|
||||||
}
|
|
||||||
|
|
||||||
var reason: Reason? = reason
|
|
||||||
private set
|
|
||||||
}
|
|
||||||
|
|
||||||
private var updateJob: Job? = null
|
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow<ReleaseManagerState>(ReleaseManagerState.Finished())
|
|
||||||
val uiState = _uiState.asStateFlow()
|
|
||||||
|
|
||||||
val isInUpdate: Boolean
|
|
||||||
get() = _uiState.value !is ReleaseManagerState.Failure && _uiState.value !is ReleaseManagerState.Finished
|
|
||||||
|
|
||||||
private fun sendError(e: Exception) {
|
|
||||||
_uiState.value = ReleaseManagerState.Failure(e)
|
|
||||||
|
|
||||||
// ignore cancellation, it's good actually
|
|
||||||
if (e !is CancellationException && e !is InterruptedIOException) {
|
|
||||||
Log.w("Geode", "Release download has failed:")
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getLatestRelease(): Release? {
|
|
||||||
val sharedPreferences = PreferenceUtils.get(applicationContext)
|
|
||||||
val useNightly = sharedPreferences.getBoolean(PreferenceUtils.Key.RELEASE_CHANNEL)
|
|
||||||
|
|
||||||
val latestRelease = if (useNightly) {
|
|
||||||
releaseRepository.getLatestNightlyRelease()
|
|
||||||
} else {
|
|
||||||
releaseRepository.getLatestRelease()
|
|
||||||
}
|
|
||||||
|
|
||||||
return latestRelease
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun performUpdate(release: Release) {
|
|
||||||
val releaseAsset = release.getAndroidDownload()
|
|
||||||
if (releaseAsset == null) {
|
|
||||||
val noAssetException = Exception("missing Android download")
|
|
||||||
_uiState.value = ReleaseManagerState.Failure(noAssetException)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// set an initial download size
|
|
||||||
_uiState.value = ReleaseManagerState.InDownload(0, releaseAsset.size.toLong())
|
|
||||||
|
|
||||||
try {
|
|
||||||
val fileStream = DownloadUtils.downloadStream(httpClient, releaseAsset.browserDownloadUrl) { progress, outOf ->
|
|
||||||
_uiState.value = ReleaseManagerState.InDownload(progress, outOf)
|
|
||||||
}
|
|
||||||
|
|
||||||
val geodeFile = getGeodeOutputPath()
|
|
||||||
|
|
||||||
// work around a permission issue from adb push
|
|
||||||
if (geodeFile.exists()) {
|
|
||||||
geodeFile.delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
geodeFile.parentFile?.mkdirs()
|
|
||||||
|
|
||||||
DownloadUtils.extractFileFromZipStream(
|
|
||||||
fileStream,
|
|
||||||
geodeFile.outputStream(),
|
|
||||||
geodeFile.name
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
sendError(e)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// extraction performed
|
|
||||||
updatePreferences(release)
|
|
||||||
_uiState.value = ReleaseManagerState.Finished(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun fileWasExternallyModified(): Boolean {
|
|
||||||
val geodeFile = getGeodeOutputPath()
|
|
||||||
if (!geodeFile.exists()) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
val sharedPreferences = PreferenceUtils.get(applicationContext)
|
|
||||||
|
|
||||||
val fileLastModified = sharedPreferences.getLong(PreferenceUtils.Key.CURRENT_RELEASE_MODIFIED)
|
|
||||||
if (fileLastModified == 0L) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return fileLastModified != geodeFile.lastModified()
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun checkForNewRelease(allowOverwriting: Boolean = false) {
|
|
||||||
val release = try {
|
|
||||||
getLatestRelease()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
sendError(e)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (release == null) {
|
|
||||||
_uiState.value = ReleaseManagerState.Finished()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val sharedPreferences = PreferenceUtils.get(applicationContext)
|
|
||||||
|
|
||||||
val currentVersion = sharedPreferences.getLong(PreferenceUtils.Key.CURRENT_VERSION_TIMESTAMP)
|
|
||||||
val latestVersion = release.getDescriptor()
|
|
||||||
|
|
||||||
// make sure geode is still here. just in case
|
|
||||||
val geodeFile = getGeodeOutputPath()
|
|
||||||
|
|
||||||
// check if an update is needed
|
|
||||||
if (latestVersion <= currentVersion && geodeFile.exists()) {
|
|
||||||
_uiState.value = ReleaseManagerState.Finished()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if the file was externally modified
|
|
||||||
if (!allowOverwriting && fileWasExternallyModified()) {
|
|
||||||
sendError(UpdateException(UpdateException.Reason.EXTERNAL_FILE_IN_USE))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
performUpdate(release)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updatePreferences(release: Release) {
|
|
||||||
val sharedPreferences = PreferenceUtils.get(applicationContext)
|
|
||||||
|
|
||||||
sharedPreferences.setString(
|
|
||||||
PreferenceUtils.Key.CURRENT_VERSION_TAG,
|
|
||||||
release.getDescription()
|
|
||||||
)
|
|
||||||
|
|
||||||
sharedPreferences.setLong(
|
|
||||||
PreferenceUtils.Key.CURRENT_VERSION_TIMESTAMP,
|
|
||||||
release.getDescriptor()
|
|
||||||
)
|
|
||||||
|
|
||||||
val outputFile = getGeodeOutputPath()
|
|
||||||
sharedPreferences.setLong(
|
|
||||||
PreferenceUtils.Key.CURRENT_RELEASE_MODIFIED,
|
|
||||||
outputFile.lastModified()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getGeodeOutputPath(): File {
|
|
||||||
val geodeName = LaunchUtils.geodeFilename
|
|
||||||
val geodeDirectory = LaunchUtils.getBaseDirectory(applicationContext)
|
|
||||||
|
|
||||||
return File(geodeDirectory, geodeName)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancels the current update job.
|
|
||||||
*/
|
|
||||||
suspend fun cancelUpdate() {
|
|
||||||
updateJob?.cancelAndJoin()
|
|
||||||
updateJob = null
|
|
||||||
|
|
||||||
_uiState.value = ReleaseManagerState.Finished()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Schedules a new update checking job.
|
|
||||||
* @return Flow that tracks the state of the update.
|
|
||||||
*/
|
|
||||||
@OptIn(DelicateCoroutinesApi::class)
|
|
||||||
fun checkForUpdates(isManual: Boolean = false): StateFlow<ReleaseManagerState> {
|
|
||||||
if (!isInUpdate) {
|
|
||||||
_uiState.value = ReleaseManagerState.InUpdateCheck
|
|
||||||
updateJob = GlobalScope.launch {
|
|
||||||
checkForNewRelease(isManual)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return _uiState.asStateFlow()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +1,24 @@
|
|||||||
package com.geode.launcher.utils
|
package com.geode.launcher.utils
|
||||||
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
const val MS_TO_SEC = 1000L
|
const val MS_TO_SEC = 1000L
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun useCountdownTimer(
|
fun useCountdownTimer(
|
||||||
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
|
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
|
||||||
time: Long,
|
time: Duration,
|
||||||
onCountdownFinish: () -> Unit
|
onCountdownFinish: () -> Unit
|
||||||
): Long {
|
): Long {
|
||||||
var millisUntilFinished by remember {
|
var millisUntilFinished by remember {
|
||||||
mutableLongStateOf(time)
|
mutableStateOf(time)
|
||||||
}
|
}
|
||||||
|
|
||||||
var shouldBeCounting by remember {
|
var shouldBeCounting by remember {
|
||||||
@@ -28,9 +30,9 @@ fun useCountdownTimer(
|
|||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
|
|
||||||
if (millisUntilFinished > 0) {
|
if (millisUntilFinished > Duration.ZERO) {
|
||||||
delay(MS_TO_SEC)
|
delay(1.seconds)
|
||||||
millisUntilFinished -= MS_TO_SEC
|
millisUntilFinished -= 1.seconds
|
||||||
} else {
|
} else {
|
||||||
onCountdownFinish()
|
onCountdownFinish()
|
||||||
}
|
}
|
||||||
@@ -58,5 +60,5 @@ fun useCountdownTimer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return millisUntilFinished / MS_TO_SEC
|
return millisUntilFinished.inWholeSeconds
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/****************************************************************************
|
||||||
|
Copyright (c) 2010-2011 cocos2d-x.org
|
||||||
|
|
||||||
|
http://www.cocos2d-x.org
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
****************************************************************************/
|
||||||
|
package org.cocos2dx.lib
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import android.hardware.Sensor
|
||||||
|
import android.hardware.SensorEvent
|
||||||
|
import android.hardware.SensorEventListener
|
||||||
|
import android.hardware.SensorManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.view.Surface
|
||||||
|
import android.view.WindowManager
|
||||||
|
import java.lang.ref.WeakReference
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
class Cocos2dxAccelerometer : SensorEventListener {
|
||||||
|
companion object {
|
||||||
|
@JvmStatic
|
||||||
|
external fun onSensorChanged(pX: Float, pY: Float, pZ: Float, pTimestamp: Long)
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(context: Context) {
|
||||||
|
mContext = WeakReference(context)
|
||||||
|
mSensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
|
||||||
|
mAccelerometer = mSensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
|
||||||
|
val display = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
context.display
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
(context.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay
|
||||||
|
}
|
||||||
|
this.mNaturalOrientation = display.rotation
|
||||||
|
}
|
||||||
|
|
||||||
|
private val mContext: WeakReference<Context>
|
||||||
|
private val mSensorManager: SensorManager
|
||||||
|
private val mAccelerometer: Sensor?
|
||||||
|
private val mNaturalOrientation: Int
|
||||||
|
|
||||||
|
fun enable() {
|
||||||
|
this.mSensorManager.registerListener(this, this.mAccelerometer, SensorManager.SENSOR_DELAY_GAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setInterval(interval: Float) {
|
||||||
|
this.mSensorManager.registerListener(this, this.mAccelerometer, (interval * 100000).roundToInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disable() {
|
||||||
|
this.mSensorManager.unregisterListener(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSensorChanged(pSensorEvent: SensorEvent) {
|
||||||
|
if (pSensorEvent.sensor.type != Sensor.TYPE_ACCELEROMETER) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var (x, y, z) = pSensorEvent.values
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Because the axes are not swapped when the device's screen orientation
|
||||||
|
* changes. So we should swap it here. In tablets such as Motorola Xoom,
|
||||||
|
* the default orientation is landscape, so should consider this.
|
||||||
|
*/
|
||||||
|
val orientation = this.mContext.get()!!.resources.configuration.orientation
|
||||||
|
|
||||||
|
if ((orientation == Configuration.ORIENTATION_LANDSCAPE) && (this.mNaturalOrientation != Surface.ROTATION_0)) {
|
||||||
|
val tmp = x
|
||||||
|
x = -y
|
||||||
|
y = tmp
|
||||||
|
} else if ((orientation == Configuration.ORIENTATION_PORTRAIT) && (this.mNaturalOrientation != Surface.ROTATION_0)) {
|
||||||
|
val tmp = x
|
||||||
|
x = y
|
||||||
|
y = -tmp
|
||||||
|
}
|
||||||
|
|
||||||
|
Cocos2dxGLSurfaceView.queueAccelerometer(x, y, z, pSensorEvent.timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAccuracyChanged(pSensor: Sensor, pAccuracy: Int) {}
|
||||||
|
}
|
||||||
@@ -1,31 +1,45 @@
|
|||||||
package org.cocos2dx.lib
|
package org.cocos2dx.lib
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.*
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
import android.graphics.Paint.FontMetricsInt
|
import android.graphics.Paint.FontMetricsInt
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.graphics.Typeface
|
||||||
import android.text.TextPaint
|
import android.text.TextPaint
|
||||||
import android.text.TextUtils
|
import android.text.TextUtils
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
|
import androidx.core.graphics.createBitmap
|
||||||
import java.lang.ref.WeakReference
|
import java.lang.ref.WeakReference
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
import java.util.*
|
import java.util.LinkedList
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.ceil
|
import kotlin.math.ceil
|
||||||
|
|
||||||
|
private const val HORIZONTALALIGN_LEFT = 1
|
||||||
|
private const val HORIZONTALALIGN_RIGHT = 2
|
||||||
|
private const val HORIZONTALALIGN_CENTER = 3
|
||||||
|
private const val VERTICALALIGN_TOP = 1
|
||||||
|
private const val VERTICALALIGN_BOTTOM = 2
|
||||||
|
private const val VERTICALALIGN_CENTER = 3
|
||||||
|
|
||||||
@Suppress("KotlinJniMissingFunction")
|
@Suppress("KotlinJniMissingFunction")
|
||||||
object Cocos2dxBitmap {
|
object Cocos2dxBitmap {
|
||||||
private lateinit var context: WeakReference<Context>
|
private lateinit var context: WeakReference<Context>
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
private external fun nativeInitBitmapDC(pWidth: Int, pHeight: Int, pPixels: ByteArray)
|
external fun nativeInitBitmapDC(pWidth: Int, pHeight: Int, pPixels: ByteArray)
|
||||||
|
|
||||||
fun setContext(context: Context) {
|
fun setContext(context: Context) {
|
||||||
this.context = WeakReference(context)
|
this.context = WeakReference(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
@JvmStatic
|
||||||
|
@Suppress("unused")
|
||||||
fun createTextBitmap(
|
fun createTextBitmap(
|
||||||
string: String,
|
string: String,
|
||||||
fontName: String,
|
fontName: String,
|
||||||
@@ -55,7 +69,6 @@ object Cocos2dxBitmap {
|
|||||||
1.0f
|
1.0f
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
@@ -79,19 +92,18 @@ object Cocos2dxBitmap {
|
|||||||
strokeB: Float,
|
strokeB: Float,
|
||||||
strokeSize: Float
|
strokeSize: Float
|
||||||
) {
|
) {
|
||||||
val bitmapTotalHeight: Int
|
val horizontalAlignment = alignment and 0x0F
|
||||||
val horizontalAlignment = alignment and 15
|
val verticalAlignment = (alignment shr 4) and 0x0F
|
||||||
val verticalAlignment = alignment shr 4 and 15
|
|
||||||
val pString2 = refactorString(string)
|
val pString2 = refactorString(string)
|
||||||
val paint = newPaint(fontName, fontSize, horizontalAlignment)
|
val paint = newPaint(fontName, fontSize, horizontalAlignment)
|
||||||
paint.setARGB(
|
paint.setARGB(
|
||||||
MotionEvent.ACTION_MASK,
|
255,
|
||||||
(255.0 * fontTintR.toDouble()).toInt(),
|
(255.0 * fontTintR).toInt(),
|
||||||
(255.0 * fontTintG.toDouble()).toInt(),
|
(255.0 * fontTintG).toInt(),
|
||||||
(255.0 * fontTintB.toDouble()).toInt()
|
(255.0 * fontTintB).toInt()
|
||||||
)
|
)
|
||||||
val textProperty = computeTextProperty(pString2, width, height, paint)
|
val textProperty = computeTextProperty(pString2, width, height, paint)
|
||||||
bitmapTotalHeight = if (height == 0) {
|
val bitmapTotalHeight = if (height == 0) {
|
||||||
textProperty.totalHeight
|
textProperty.totalHeight
|
||||||
} else {
|
} else {
|
||||||
height
|
height
|
||||||
@@ -101,7 +113,8 @@ object Cocos2dxBitmap {
|
|||||||
var renderTextDeltaX = 0.0f
|
var renderTextDeltaX = 0.0f
|
||||||
var renderTextDeltaY = 0.0f
|
var renderTextDeltaY = 0.0f
|
||||||
if (shadow) {
|
if (shadow) {
|
||||||
paint.setShadowLayer(shadowBlur, shadowDX, shadowDY, -8553091)
|
val shadowColor = 0xff7d7d7du.toInt()
|
||||||
|
paint.setShadowLayer(shadowBlur, shadowDX, shadowDY, shadowColor)
|
||||||
bitmapPaddingX = abs(shadowDX)
|
bitmapPaddingX = abs(shadowDX)
|
||||||
bitmapPaddingY = abs(shadowDY)
|
bitmapPaddingY = abs(shadowDY)
|
||||||
if (shadowDX.toDouble() < 0.0) {
|
if (shadowDX.toDouble() < 0.0) {
|
||||||
@@ -111,56 +124,47 @@ object Cocos2dxBitmap {
|
|||||||
renderTextDeltaY = bitmapPaddingY
|
renderTextDeltaY = bitmapPaddingY
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val bitmap = Bitmap.createBitmap(
|
|
||||||
textProperty.maxWidth + bitmapPaddingX.toInt(),
|
val bitmap = try {
|
||||||
bitmapPaddingY.toInt() + bitmapTotalHeight,
|
createBitmap(
|
||||||
Bitmap.Config.ARGB_8888
|
textProperty.maxWidth + bitmapPaddingX.toInt(),
|
||||||
)
|
bitmapTotalHeight + bitmapPaddingY.toInt()
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// bandaid fix, hopefully someone checks logs to see this
|
||||||
|
Log.e("Cocos2dxBitmap", "Error creating bitmap: $e")
|
||||||
|
nativeInitBitmapDC(0, 0, ByteArray(0))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val canvas = Canvas(bitmap)
|
val canvas = Canvas(bitmap)
|
||||||
val fontMetricsInt = paint.fontMetricsInt
|
val fontMetricsInt = paint.fontMetricsInt
|
||||||
var y = computeY(fontMetricsInt, height, textProperty.totalHeight, verticalAlignment)
|
var y = computeY(fontMetricsInt, height, textProperty.totalHeight, verticalAlignment)
|
||||||
val lines = textProperty.lines
|
val lines = textProperty.lines
|
||||||
val length = lines.size
|
|
||||||
for (i in 0 until length) {
|
for (line in lines) {
|
||||||
val line = lines[i]
|
val x = computeX(line, textProperty.maxWidth, horizontalAlignment)
|
||||||
canvas.drawText(
|
canvas.drawText(line, x + renderTextDeltaX, y + renderTextDeltaY, paint)
|
||||||
line!!,
|
|
||||||
computeX(
|
|
||||||
line,
|
|
||||||
textProperty.maxWidth,
|
|
||||||
horizontalAlignment
|
|
||||||
).toFloat() + renderTextDeltaX,
|
|
||||||
y.toFloat() + renderTextDeltaY,
|
|
||||||
paint
|
|
||||||
)
|
|
||||||
y += textProperty.heightPerLine
|
y += textProperty.heightPerLine
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stroke) {
|
if (stroke) {
|
||||||
val paintStroke = newPaint(fontName, fontSize, horizontalAlignment)
|
val paintStroke = newPaint(fontName, fontSize, horizontalAlignment)
|
||||||
paintStroke.style = Paint.Style.STROKE
|
paintStroke.style = Paint.Style.STROKE
|
||||||
paintStroke.strokeWidth = 0.5f * strokeSize
|
paintStroke.strokeWidth = strokeSize * 0.5f
|
||||||
paintStroke.setARGB(
|
paintStroke.setARGB(
|
||||||
MotionEvent.ACTION_MASK,
|
255,
|
||||||
strokeR.toInt() * MotionEvent.ACTION_MASK,
|
strokeR.toInt() * MotionEvent.ACTION_MASK,
|
||||||
strokeG.toInt() * MotionEvent.ACTION_MASK,
|
strokeG.toInt() * MotionEvent.ACTION_MASK,
|
||||||
strokeB.toInt() * MotionEvent.ACTION_MASK
|
strokeB.toInt() * MotionEvent.ACTION_MASK
|
||||||
)
|
)
|
||||||
var y2 = computeY(fontMetricsInt, height, textProperty.totalHeight, verticalAlignment)
|
var y = computeY(fontMetricsInt, height, textProperty.totalHeight, verticalAlignment)
|
||||||
val lines2 = textProperty.lines
|
val lines = textProperty.lines
|
||||||
val length2 = lines2.size
|
|
||||||
for (i2 in 0 until length2) {
|
for (line in lines) {
|
||||||
val line2 = lines2[i2]
|
val x = computeX(line, textProperty.maxWidth, horizontalAlignment)
|
||||||
canvas.drawText(
|
canvas.drawText(line, x + renderTextDeltaX, y + renderTextDeltaY, paintStroke)
|
||||||
line2!!,
|
y += textProperty.heightPerLine
|
||||||
computeX(
|
|
||||||
line2,
|
|
||||||
textProperty.maxWidth,
|
|
||||||
horizontalAlignment
|
|
||||||
).toFloat() + renderTextDeltaX,
|
|
||||||
y2.toFloat() + renderTextDeltaY,
|
|
||||||
paintStroke
|
|
||||||
)
|
|
||||||
y2 += textProperty.heightPerLine
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
initNativeObject(bitmap)
|
initNativeObject(bitmap)
|
||||||
@@ -168,24 +172,24 @@ object Cocos2dxBitmap {
|
|||||||
|
|
||||||
private fun newPaint(fontName: String, fontSize: Int, horizontalAlignment: Int): Paint {
|
private fun newPaint(fontName: String, fontSize: Int, horizontalAlignment: Int): Paint {
|
||||||
val paint = Paint()
|
val paint = Paint()
|
||||||
paint.color = -1
|
paint.color = Color.WHITE
|
||||||
paint.textSize = fontSize.toFloat()
|
paint.textSize = fontSize.toFloat()
|
||||||
paint.isAntiAlias = true
|
paint.isAntiAlias = true
|
||||||
if (fontName.endsWith(".ttf")) {
|
if (fontName.endsWith(".ttf")) {
|
||||||
try {
|
try {
|
||||||
context.get()?.apply {
|
context.get()!!.apply {
|
||||||
paint.typeface = Cocos2dxTypefaces[this, fontName]
|
paint.typeface = Cocos2dxTypefaces[this, fontName]
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("Cocos2dxBitmap", "error to create ttf type face: $fontName")
|
Log.e("Cocos2dxBitmap", "error to create ttf type face: $fontName - $e")
|
||||||
paint.typeface = Typeface.create(fontName, Typeface.NORMAL)
|
paint.typeface = Typeface.create(fontName, Typeface.NORMAL)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
paint.typeface = Typeface.create(fontName, Typeface.NORMAL)
|
paint.typeface = Typeface.create(fontName, Typeface.NORMAL)
|
||||||
}
|
}
|
||||||
when (horizontalAlignment) {
|
when (horizontalAlignment) {
|
||||||
2 -> paint.textAlign = Paint.Align.RIGHT
|
HORIZONTALALIGN_RIGHT -> paint.textAlign = Paint.Align.RIGHT
|
||||||
3 -> paint.textAlign = Paint.Align.CENTER
|
HORIZONTALALIGN_CENTER -> paint.textAlign = Paint.Align.CENTER
|
||||||
else -> paint.textAlign = Paint.Align.LEFT
|
else -> paint.textAlign = Paint.Align.LEFT
|
||||||
}
|
}
|
||||||
return paint
|
return paint
|
||||||
@@ -198,14 +202,14 @@ object Cocos2dxBitmap {
|
|||||||
paint: Paint
|
paint: Paint
|
||||||
): TextProperty {
|
): TextProperty {
|
||||||
val fm = paint.fontMetricsInt
|
val fm = paint.fontMetricsInt
|
||||||
val h = ceil((fm.bottom - fm.top).toDouble()).toInt()
|
val h = fm.bottom - fm.top
|
||||||
var maxContentWidth = 0
|
var maxContentWidth = 0
|
||||||
val lines = splitString(string, width, height, paint)
|
val lines = splitString(string, width, height, paint)
|
||||||
if (width != 0) {
|
if (width != 0) {
|
||||||
maxContentWidth = width
|
maxContentWidth = width
|
||||||
} else {
|
} else {
|
||||||
for (line in lines) {
|
for (line in lines) {
|
||||||
val temp = ceil(paint.measureText(line, 0, line!!.length).toDouble()).toInt()
|
val temp = ceil(paint.measureText(line, 0, line.length)).toInt()
|
||||||
if (temp > maxContentWidth) {
|
if (temp > maxContentWidth) {
|
||||||
maxContentWidth = temp
|
maxContentWidth = temp
|
||||||
}
|
}
|
||||||
@@ -214,10 +218,10 @@ object Cocos2dxBitmap {
|
|||||||
return TextProperty(maxContentWidth, h, lines)
|
return TextProperty(maxContentWidth, h, lines)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun computeX(@Suppress("UNUSED_PARAMETER") text: String?, maxWidth: Int, horizontalAlignment: Int): Int {
|
private fun computeX(@Suppress("UNUSED_PARAMETER") text: String, maxWidth: Int, horizontalAlignment: Int): Int {
|
||||||
return when (horizontalAlignment) {
|
return when (horizontalAlignment) {
|
||||||
2 -> maxWidth
|
HORIZONTALALIGN_RIGHT -> maxWidth
|
||||||
3 -> maxWidth / 2
|
HORIZONTALALIGN_CENTER -> maxWidth / 2
|
||||||
else -> 0
|
else -> 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,13 +233,15 @@ object Cocos2dxBitmap {
|
|||||||
verticalAlignment: Int
|
verticalAlignment: Int
|
||||||
): Int {
|
): Int {
|
||||||
val y = -fontMetricsInt.top
|
val y = -fontMetricsInt.top
|
||||||
return if (constrainHeight <= totalHeight) {
|
return if (constrainHeight > totalHeight) {
|
||||||
|
when (verticalAlignment) {
|
||||||
|
VERTICALALIGN_TOP -> -fontMetricsInt.top
|
||||||
|
VERTICALALIGN_CENTER -> -fontMetricsInt.top + (constrainHeight - totalHeight) / 2
|
||||||
|
VERTICALALIGN_BOTTOM -> -fontMetricsInt.top + (constrainHeight - totalHeight)
|
||||||
|
else -> y
|
||||||
|
}
|
||||||
|
} else {
|
||||||
y
|
y
|
||||||
} else when (verticalAlignment) {
|
|
||||||
1 -> -fontMetricsInt.top
|
|
||||||
2 -> -fontMetricsInt.top + (constrainHeight - totalHeight)
|
|
||||||
3 -> -fontMetricsInt.top + (constrainHeight - totalHeight) / 2
|
|
||||||
else -> y
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,56 +250,56 @@ object Cocos2dxBitmap {
|
|||||||
maxWidth: Int,
|
maxWidth: Int,
|
||||||
maxHeight: Int,
|
maxHeight: Int,
|
||||||
paint: Paint
|
paint: Paint
|
||||||
): Array<String?> {
|
): List<String> {
|
||||||
val lines: Array<String?> = string.split("\\n").toTypedArray()
|
val lines = string.split("\n")
|
||||||
val fm = paint.fontMetricsInt
|
val fm = paint.fontMetricsInt
|
||||||
val maxLines = maxHeight / ceil((fm.bottom - fm.top).toDouble()).toInt()
|
val heightPerLine = fm.bottom - fm.top
|
||||||
|
val maxLines = maxHeight / heightPerLine
|
||||||
return if (maxWidth != 0) {
|
return if (maxWidth != 0) {
|
||||||
val strList = LinkedList<String?>()
|
val strList = LinkedList<String>()
|
||||||
for (line in lines) {
|
for (line in lines) {
|
||||||
if (ceil(paint.measureText(line).toDouble()).toInt() > maxWidth) {
|
val lineWidth = ceil(paint.measureText(line)).toInt()
|
||||||
|
|
||||||
|
if (lineWidth > maxWidth) {
|
||||||
strList.addAll(divideStringWithMaxWidth(line, maxWidth, paint))
|
strList.addAll(divideStringWithMaxWidth(line, maxWidth, paint))
|
||||||
} else {
|
} else {
|
||||||
strList.add(line)
|
strList.add(line)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (maxLines > 0 && strList.size >= maxLines) {
|
if (maxLines > 0 && strList.size >= maxLines) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (maxLines > 0 && strList.size > maxLines) {
|
if (maxLines > 0 && strList.size > maxLines) {
|
||||||
while (strList.size > maxLines) {
|
while (strList.size > maxLines) {
|
||||||
strList.removeLast()
|
strList.removeLast()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val ret = arrayOfNulls<String>(strList.size)
|
|
||||||
strList.toArray(ret)
|
strList
|
||||||
ret
|
} else if (maxHeight != 0 && lines.size > maxLines) {
|
||||||
} else if (maxHeight == 0 || lines.size <= maxLines) {
|
lines.take(maxLines)
|
||||||
lines
|
|
||||||
} else {
|
} else {
|
||||||
val strList2 = LinkedList<String?>()
|
lines
|
||||||
for (i in 0 until maxLines) {
|
|
||||||
strList2.add(lines[i])
|
|
||||||
}
|
|
||||||
val ret2 = arrayOfNulls<String>(strList2.size)
|
|
||||||
strList2.toArray(ret2)
|
|
||||||
ret2
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun divideStringWithMaxWidth(
|
private fun divideStringWithMaxWidth(
|
||||||
string: String?,
|
string: String,
|
||||||
maxWidth: Int,
|
maxWidth: Int,
|
||||||
paint: Paint
|
paint: Paint
|
||||||
): LinkedList<String?> {
|
): LinkedList<String> {
|
||||||
val charLength = string!!.length
|
val charLength = string.length
|
||||||
var start = 0
|
var start = 0
|
||||||
val strList = LinkedList<String?>()
|
val strList = LinkedList<String>()
|
||||||
|
|
||||||
var i = 1
|
var i = 1
|
||||||
while (i <= charLength) {
|
while (i <= charLength) {
|
||||||
val tempWidth = ceil(paint.measureText(string, start, i).toDouble()).toInt()
|
val tempWidth = ceil(paint.measureText(string, start, i)).toInt()
|
||||||
if (tempWidth >= maxWidth) {
|
if (tempWidth >= maxWidth) {
|
||||||
val lastIndexOfSpace = string.substring(0, i).lastIndexOf(" ")
|
val lastIndexOfSpace = string.substring(0, i).lastIndexOf(" ")
|
||||||
|
|
||||||
if (lastIndexOfSpace != -1 && lastIndexOfSpace > start) {
|
if (lastIndexOfSpace != -1 && lastIndexOfSpace > start) {
|
||||||
strList.add(string.substring(start, lastIndexOfSpace))
|
strList.add(string.substring(start, lastIndexOfSpace))
|
||||||
i = lastIndexOfSpace + 1
|
i = lastIndexOfSpace + 1
|
||||||
@@ -303,16 +309,20 @@ object Cocos2dxBitmap {
|
|||||||
} else {
|
} else {
|
||||||
strList.add(string.substring(start, i))
|
strList.add(string.substring(start, i))
|
||||||
}
|
}
|
||||||
|
|
||||||
while (i < charLength && string[i] == ' ') {
|
while (i < charLength && string[i] == ' ') {
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
|
|
||||||
start = i
|
start = i
|
||||||
}
|
}
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
|
|
||||||
if (start < charLength) {
|
if (start < charLength) {
|
||||||
strList.add(string.substring(start))
|
strList.add(string.substring(start))
|
||||||
}
|
}
|
||||||
|
|
||||||
return strList
|
return strList
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,19 +330,22 @@ object Cocos2dxBitmap {
|
|||||||
if (pString.compareTo("") == 0) {
|
if (pString.compareTo("") == 0) {
|
||||||
return " "
|
return " "
|
||||||
}
|
}
|
||||||
|
|
||||||
val strBuilder = StringBuilder(pString)
|
val strBuilder = StringBuilder(pString)
|
||||||
var start = 0
|
var start = 0
|
||||||
var index = strBuilder.indexOf("\n")
|
var index = strBuilder.indexOf("\n")
|
||||||
while (index != -1) {
|
while (index != -1) {
|
||||||
start = if (index == 0 || strBuilder[index - 1].code == 10) {
|
start = if (index == 0 || strBuilder[index - 1] == '\n') {
|
||||||
strBuilder.insert(start, " ")
|
strBuilder.insert(start, " ")
|
||||||
index + 2
|
index + 2
|
||||||
} else {
|
} else {
|
||||||
index + 1
|
index + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
if (start > strBuilder.length || index == strBuilder.length) {
|
if (start > strBuilder.length || index == strBuilder.length) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
index = strBuilder.indexOf("\n", start)
|
index = strBuilder.indexOf("\n", start)
|
||||||
}
|
}
|
||||||
return strBuilder.toString()
|
return strBuilder.toString()
|
||||||
@@ -340,15 +353,10 @@ object Cocos2dxBitmap {
|
|||||||
|
|
||||||
private fun initNativeObject(pBitmap: Bitmap) {
|
private fun initNativeObject(pBitmap: Bitmap) {
|
||||||
val pixels = getPixels(pBitmap)
|
val pixels = getPixels(pBitmap)
|
||||||
if (pixels != null) {
|
nativeInitBitmapDC(pBitmap.width, pBitmap.height, pixels)
|
||||||
nativeInitBitmapDC(pBitmap.width, pBitmap.height, pixels)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getPixels(pBitmap: Bitmap?): ByteArray? {
|
private fun getPixels(pBitmap: Bitmap): ByteArray {
|
||||||
if (pBitmap == null) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
val pixels = ByteArray(pBitmap.width * pBitmap.height * 4)
|
val pixels = ByteArray(pBitmap.width * pBitmap.height * 4)
|
||||||
val buf = ByteBuffer.wrap(pixels)
|
val buf = ByteBuffer.wrap(pixels)
|
||||||
buf.order(ByteOrder.nativeOrder())
|
buf.order(ByteOrder.nativeOrder())
|
||||||
@@ -361,13 +369,18 @@ object Cocos2dxBitmap {
|
|||||||
private fun getFontSizeAccordingHeight(height: Int): Int {
|
private fun getFontSizeAccordingHeight(height: Int): Int {
|
||||||
val paint = Paint()
|
val paint = Paint()
|
||||||
val bounds = Rect()
|
val bounds = Rect()
|
||||||
|
|
||||||
paint.typeface = Typeface.DEFAULT
|
paint.typeface = Typeface.DEFAULT
|
||||||
var incrTextSize = 1
|
var incrTextSize = 1
|
||||||
var foundDesiredSize = false
|
var foundDesiredSize = false
|
||||||
|
|
||||||
while (!foundDesiredSize) {
|
while (!foundDesiredSize) {
|
||||||
paint.textSize = incrTextSize.toFloat()
|
paint.textSize = incrTextSize.toFloat()
|
||||||
paint.getTextBounds("SghMNy", 0, "SghMNy".length, bounds)
|
val text = "SghMNy"
|
||||||
|
paint.getTextBounds(text, 0, text.length, bounds)
|
||||||
|
|
||||||
incrTextSize++
|
incrTextSize++
|
||||||
|
|
||||||
if (height - bounds.height() <= 2) {
|
if (height - bounds.height() <= 2) {
|
||||||
foundDesiredSize = true
|
foundDesiredSize = true
|
||||||
}
|
}
|
||||||
@@ -382,23 +395,19 @@ object Cocos2dxBitmap {
|
|||||||
if (TextUtils.isEmpty(string)) {
|
if (TextUtils.isEmpty(string)) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
val paint = TextPaint()
|
val paint = TextPaint()
|
||||||
paint.typeface = Typeface.DEFAULT
|
paint.typeface = Typeface.DEFAULT
|
||||||
paint.textSize = fontSize
|
paint.textSize = fontSize
|
||||||
|
|
||||||
return TextUtils.ellipsize(string, paint, width, TextUtils.TruncateAt.END).toString()
|
return TextUtils.ellipsize(string, paint, width, TextUtils.TruncateAt.END).toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
private class TextProperty constructor(
|
private class TextProperty(
|
||||||
val maxWidth: Int,
|
val maxWidth: Int,
|
||||||
val heightPerLine: Int, pLines: Array<String?>
|
val heightPerLine: Int,
|
||||||
|
val lines: Collection<String>
|
||||||
) {
|
) {
|
||||||
val lines: Array<String?>
|
val totalHeight: Int = lines.size * heightPerLine
|
||||||
|
|
||||||
val totalHeight: Int
|
|
||||||
|
|
||||||
init {
|
|
||||||
totalHeight = pLines.size * heightPerLine
|
|
||||||
lines = pLines
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package org.cocos2dx.lib
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.view.KeyEvent
|
|
||||||
import androidx.appcompat.widget.AppCompatEditText
|
|
||||||
|
|
||||||
class Cocos2dxEditText(context: Context) : AppCompatEditText(context) {
|
|
||||||
var cocos2dxGLSurfaceView: Cocos2dxGLSurfaceView? = null
|
|
||||||
|
|
||||||
override fun onKeyDown(keyCode: Int, keyEvent: KeyEvent): Boolean {
|
|
||||||
super.onKeyDown(keyCode, keyEvent)
|
|
||||||
if (keyCode != KeyEvent.KEYCODE_BACK) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
cocos2dxGLSurfaceView?.requestFocus()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +1,60 @@
|
|||||||
package org.cocos2dx.lib
|
package org.cocos2dx.lib
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.hardware.input.InputManager
|
||||||
import android.opengl.GLSurfaceView
|
import android.opengl.GLSurfaceView
|
||||||
|
import android.os.Build
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.os.Message
|
import android.os.Message
|
||||||
import android.util.Log
|
import android.text.InputType
|
||||||
|
import android.view.InputDevice
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
|
import android.view.Surface
|
||||||
|
import android.view.WindowInsets
|
||||||
|
import android.view.inputmethod.EditorInfo
|
||||||
import android.view.inputmethod.InputMethodManager
|
import android.view.inputmethod.InputMethodManager
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.appcompat.widget.AppCompatEditText
|
||||||
import com.customRobTop.BaseRobTopActivity
|
import com.customRobTop.BaseRobTopActivity
|
||||||
|
import com.geode.launcher.utils.GeodeUtils
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
private const val HANDLER_OPEN_IME_KEYBOARD = 2
|
private const val HANDLER_OPEN_IME_KEYBOARD = 2
|
||||||
private const val HANDLER_CLOSE_IME_KEYBOARD = 3
|
private const val HANDLER_CLOSE_IME_KEYBOARD = 3
|
||||||
|
private const val MS_TO_NS = 1_000_000
|
||||||
|
private const val SEC_TO_NS = 1_000_000_000.0
|
||||||
|
|
||||||
|
fun inputTypeForFlags(i: Int): Int {
|
||||||
|
if ((i and BaseRobTopActivity.KEYBOARD_INPUT_NUMERIC) != 0) {
|
||||||
|
return InputType.TYPE_NUMBER_FLAG_DECIMAL or InputType.TYPE_NUMBER_FLAG_SIGNED or InputType.TYPE_CLASS_NUMBER
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((i and BaseRobTopActivity.KEYBOARD_INPUT_PASSWORD) != 0) {
|
||||||
|
return InputType.TYPE_TEXT_VARIATION_PASSWORD or InputType.TYPE_CLASS_TEXT
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((i and BaseRobTopActivity.KEYBOARD_INPUT_MULTILINE) != 0) {
|
||||||
|
return InputType.TYPE_TEXT_FLAG_MULTI_LINE or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or InputType.TYPE_CLASS_TEXT
|
||||||
|
}
|
||||||
|
|
||||||
|
return InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS or InputType.TYPE_CLASS_TEXT
|
||||||
|
}
|
||||||
|
|
||||||
|
fun usesInlineKeyboard(): Boolean {
|
||||||
|
return Build.VERSION.SDK_INT >= 30
|
||||||
|
}
|
||||||
|
|
||||||
|
fun imeOptionsForFlags(i: Int): Int {
|
||||||
|
val i2 = if ((i and BaseRobTopActivity.KEYBOARD_INPUT_MULTILINE) == 0 || !usesInlineKeyboard())
|
||||||
|
EditorInfo.IME_ACTION_DONE else EditorInfo.IME_ACTION_NONE
|
||||||
|
|
||||||
|
return if (usesInlineKeyboard())
|
||||||
|
EditorInfo.IME_FLAG_NO_FULLSCREEN or EditorInfo.IME_FLAG_NO_EXTRACT_UI or i2
|
||||||
|
else i2
|
||||||
|
}
|
||||||
|
|
||||||
class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
||||||
companion object {
|
companion object {
|
||||||
@@ -20,6 +62,19 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
|||||||
private lateinit var handler: Handler
|
private lateinit var handler: Handler
|
||||||
private lateinit var cocos2dxTextInputWrapper: Cocos2dxTextInputWrapper
|
private lateinit var cocos2dxTextInputWrapper: Cocos2dxTextInputWrapper
|
||||||
|
|
||||||
|
private var keyboardVisible = false
|
||||||
|
private var keyboardHeight = 0
|
||||||
|
|
||||||
|
private var keyboardHideCheck = Runnable {
|
||||||
|
if (keyboardVisible) {
|
||||||
|
return@Runnable
|
||||||
|
}
|
||||||
|
|
||||||
|
cocos2dxGLSurfaceView.queueEvent {
|
||||||
|
cocos2dxGLSurfaceView.cocos2dxRenderer.handleTextClosed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun openIMEKeyboard() {
|
fun openIMEKeyboard() {
|
||||||
val msg = Message()
|
val msg = Message()
|
||||||
@@ -34,25 +89,65 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
|||||||
msg.what = HANDLER_CLOSE_IME_KEYBOARD
|
msg.what = HANDLER_CLOSE_IME_KEYBOARD
|
||||||
handler.sendMessage(msg)
|
handler.sendMessage(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun queueAccelerometer(x: Float, y: Float, z: Float, timestamp: Long) =
|
||||||
|
cocos2dxGLSurfaceView.queueEvent {
|
||||||
|
Cocos2dxAccelerometer.onSensorChanged(x, y, z, timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.R)
|
||||||
|
fun onKeyboardInsets(windowInsets: WindowInsets) {
|
||||||
|
if (!cocos2dxGLSurfaceView.sendNewCocosEvents) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val visible = windowInsets.isVisible(WindowInsets.Type.ime())
|
||||||
|
val offsetHeight = if (visible) windowInsets.getInsets(WindowInsets.Type.ime()).bottom else 0
|
||||||
|
|
||||||
|
if (visible == keyboardVisible && offsetHeight == keyboardHeight) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
keyboardVisible = visible
|
||||||
|
keyboardHeight = offsetHeight
|
||||||
|
|
||||||
|
handler.removeCallbacks(keyboardHideCheck)
|
||||||
|
if (!visible) {
|
||||||
|
handler.postDelayed(keyboardHideCheck, 150L)
|
||||||
|
} else {
|
||||||
|
cocos2dxGLSurfaceView.cocos2dxRenderer.handleKeyboardWillShow(keyboardHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private lateinit var cocos2dxRenderer: Cocos2dxRenderer
|
private lateinit var cocos2dxRenderer: Cocos2dxRenderer
|
||||||
|
var useKeyboardEvents = false
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
cocos2dxRenderer.sendResizeEvents = value
|
||||||
|
}
|
||||||
|
|
||||||
var cocos2dxEditText: Cocos2dxEditText? = null
|
var sendTimestampEvents = false
|
||||||
|
var manualBackEvents = false
|
||||||
|
var controllerEventsEnabled = false
|
||||||
|
private set
|
||||||
|
var supportsResizeEvents = false
|
||||||
|
|
||||||
|
// 2.209+ events for keyboard and such
|
||||||
|
var sendNewCocosEvents = false
|
||||||
|
|
||||||
|
var cocos2dxEditText: AppCompatEditText? = null
|
||||||
set(value) {
|
set(value) {
|
||||||
field = value
|
field = value
|
||||||
|
|
||||||
field?.setOnEditorActionListener(cocos2dxTextInputWrapper)
|
field?.setOnEditorActionListener(cocos2dxTextInputWrapper)
|
||||||
field?.cocos2dxGLSurfaceView = this
|
|
||||||
requestFocus()
|
requestFocus()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun initView() {
|
fun initView() {
|
||||||
setEGLContextClientVersion(2)
|
setEGLContextClientVersion(2)
|
||||||
isFocusableInTouchMode = true
|
isFocusableInTouchMode = true
|
||||||
// if (Build.VERSION.SDK_INT >= 11) {
|
|
||||||
preserveEGLContextOnPause = true
|
preserveEGLContextOnPause = true
|
||||||
// }
|
|
||||||
cocos2dxGLSurfaceView = this
|
cocos2dxGLSurfaceView = this
|
||||||
cocos2dxTextInputWrapper = Cocos2dxTextInputWrapper(this)
|
cocos2dxTextInputWrapper = Cocos2dxTextInputWrapper(this)
|
||||||
Cocos2dxGLSurfaceView.handler = object : Handler(Looper.getMainLooper()) {
|
Cocos2dxGLSurfaceView.handler = object : Handler(Looper.getMainLooper()) {
|
||||||
@@ -61,9 +156,13 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
|||||||
HANDLER_OPEN_IME_KEYBOARD -> {
|
HANDLER_OPEN_IME_KEYBOARD -> {
|
||||||
if (cocos2dxEditText?.requestFocus() == true) {
|
if (cocos2dxEditText?.requestFocus() == true) {
|
||||||
cocos2dxEditText?.apply {
|
cocos2dxEditText?.apply {
|
||||||
removeTextChangedListener(
|
removeTextChangedListener(cocos2dxTextInputWrapper)
|
||||||
cocos2dxTextInputWrapper
|
|
||||||
)
|
if (sendNewCocosEvents) {
|
||||||
|
inputType = inputTypeForFlags(BaseRobTopActivity.curKeyboardInputFlags)
|
||||||
|
imeOptions = imeOptionsForFlags(BaseRobTopActivity.curKeyboardInputFlags)
|
||||||
|
}
|
||||||
|
|
||||||
setText("")
|
setText("")
|
||||||
val text = msg.obj as String
|
val text = msg.obj as String
|
||||||
append(text)
|
append(text)
|
||||||
@@ -76,16 +175,13 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
|||||||
cocos2dxEditText,
|
cocos2dxEditText,
|
||||||
0
|
0
|
||||||
)
|
)
|
||||||
Log.d("GLSurfaceView", "showSoftInput")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
HANDLER_CLOSE_IME_KEYBOARD -> {
|
HANDLER_CLOSE_IME_KEYBOARD -> {
|
||||||
cocos2dxEditText?.apply {
|
cocos2dxEditText?.apply {
|
||||||
removeTextChangedListener(
|
removeTextChangedListener(cocos2dxTextInputWrapper)
|
||||||
cocos2dxTextInputWrapper
|
|
||||||
)
|
|
||||||
(context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(
|
(context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(
|
||||||
windowToken,
|
windowToken,
|
||||||
0
|
0
|
||||||
@@ -93,8 +189,10 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
requestFocus()
|
requestFocus()
|
||||||
Log.d("GLSurfaceView", "HideSoftInput")
|
|
||||||
cocos2dxRenderer.handleTextClosed()
|
queueEvent {
|
||||||
|
cocos2dxRenderer.handleTextClosed()
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
else -> return
|
else -> return
|
||||||
@@ -103,21 +201,50 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.R)
|
||||||
|
fun updateRefreshRate() {
|
||||||
|
val chosenDisplay = display.supportedModes?.maxByOrNull { it.refreshRate }
|
||||||
|
if (chosenDisplay == null) {
|
||||||
|
println("updateRefreshRate failed to find a display to maximize refresh rate...")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val chosenRefreshRate = chosenDisplay.refreshRate
|
||||||
|
|
||||||
|
println("updateRefreshRate: selecting refresh rate of ${chosenDisplay.refreshRate} (display ${chosenDisplay.modeId})")
|
||||||
|
|
||||||
|
holder.surface.setFrameRate(chosenRefreshRate, Surface.FRAME_RATE_COMPATIBILITY_DEFAULT)
|
||||||
|
if (isAttachedToWindow) {
|
||||||
|
(context as Activity).window.attributes.preferredRefreshRate = chosenRefreshRate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
queueEvent { cocos2dxRenderer.handleOnPause() }
|
queueEvent { cocos2dxRenderer.handleOnPause() }
|
||||||
|
renderMode = RENDERMODE_WHEN_DIRTY
|
||||||
super.onPause()
|
super.onPause()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
|
renderMode = RENDERMODE_CONTINUOUSLY
|
||||||
queueEvent { cocos2dxRenderer.handleOnResume() }
|
queueEvent { cocos2dxRenderer.handleOnResume() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun sendNextEventTimestamp(timestamp: Long) {
|
||||||
|
if (sendTimestampEvents)
|
||||||
|
GeodeUtils.setNextInputTimestamp(timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onTouchEvent(motionEvent: MotionEvent): Boolean {
|
override fun onTouchEvent(motionEvent: MotionEvent): Boolean {
|
||||||
val pointerNumber = motionEvent.pointerCount
|
val pointerNumber = motionEvent.pointerCount
|
||||||
val ids = IntArray(pointerNumber)
|
val ids = IntArray(pointerNumber)
|
||||||
val xs = FloatArray(pointerNumber)
|
val xs = FloatArray(pointerNumber)
|
||||||
val ys = FloatArray(pointerNumber)
|
val ys = FloatArray(pointerNumber)
|
||||||
|
val timestamp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||||
|
motionEvent.eventTimeNanos else motionEvent.eventTime * MS_TO_NS
|
||||||
|
val timestampSec = timestamp / SEC_TO_NS
|
||||||
|
|
||||||
for (i in 0 until pointerNumber) {
|
for (i in 0 until pointerNumber) {
|
||||||
ids[i] = motionEvent.getPointerId(i)
|
ids[i] = motionEvent.getPointerId(i)
|
||||||
xs[i] = motionEvent.getX(i)
|
xs[i] = motionEvent.getX(i)
|
||||||
@@ -129,11 +256,16 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
|||||||
val xDown = xs[0]
|
val xDown = xs[0]
|
||||||
val f = ys[0]
|
val f = ys[0]
|
||||||
queueEvent {
|
queueEvent {
|
||||||
cocos2dxRenderer.handleActionDown(
|
sendNextEventTimestamp(timestamp)
|
||||||
idDown,
|
|
||||||
xDown,
|
if (controllerEventsEnabled)
|
||||||
f
|
GeodeUtils.internalTouchEvent(
|
||||||
)
|
timestamp, motionEvent.deviceId, motionEvent.source,
|
||||||
|
// slice here to convert to single element array
|
||||||
|
GeodeUtils.TOUCH_TYPE_BEGIN, ids.sliceArray(0..0), xs.sliceArray(0..0), ys.sliceArray(0..0)
|
||||||
|
)
|
||||||
|
else
|
||||||
|
cocos2dxRenderer.handleActionDown(idDown, xDown, f, timestampSec)
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -141,26 +273,44 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
|||||||
val idUp = motionEvent.getPointerId(0)
|
val idUp = motionEvent.getPointerId(0)
|
||||||
val f2 = xs[0]
|
val f2 = xs[0]
|
||||||
val f3 = ys[0]
|
val f3 = ys[0]
|
||||||
queueEvent { cocos2dxRenderer.handleActionUp(idUp, f2, f3) }
|
queueEvent {
|
||||||
|
sendNextEventTimestamp(timestamp)
|
||||||
|
|
||||||
|
if (controllerEventsEnabled)
|
||||||
|
GeodeUtils.internalTouchEvent(
|
||||||
|
timestamp, motionEvent.deviceId, motionEvent.source,
|
||||||
|
GeodeUtils.TOUCH_TYPE_END, ids.sliceArray(0..0), xs.sliceArray(0..0), ys.sliceArray(0..0)
|
||||||
|
)
|
||||||
|
else
|
||||||
|
cocos2dxRenderer.handleActionUp(idUp, f2, f3, timestampSec)
|
||||||
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
MotionEvent.ACTION_MOVE -> {
|
MotionEvent.ACTION_MOVE -> {
|
||||||
queueEvent {
|
queueEvent {
|
||||||
cocos2dxRenderer.handleActionMove(
|
sendNextEventTimestamp(timestamp)
|
||||||
ids,
|
|
||||||
xs,
|
if (controllerEventsEnabled)
|
||||||
ys
|
GeodeUtils.internalTouchEvent(
|
||||||
)
|
timestamp, motionEvent.deviceId, motionEvent.source,
|
||||||
|
GeodeUtils.TOUCH_TYPE_MOVE, ids, xs, ys
|
||||||
|
)
|
||||||
|
else
|
||||||
|
cocos2dxRenderer.handleActionMove(ids, xs, ys, timestampSec)
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
MotionEvent.ACTION_CANCEL -> {
|
MotionEvent.ACTION_CANCEL -> {
|
||||||
queueEvent {
|
queueEvent {
|
||||||
cocos2dxRenderer.handleActionCancel(
|
sendNextEventTimestamp(timestamp)
|
||||||
ids,
|
|
||||||
xs,
|
if (controllerEventsEnabled)
|
||||||
ys
|
GeodeUtils.internalTouchEvent(
|
||||||
)
|
timestamp, motionEvent.deviceId, motionEvent.source,
|
||||||
|
GeodeUtils.TOUCH_TYPE_CANCEL, ids, xs, ys
|
||||||
|
)
|
||||||
|
else
|
||||||
|
cocos2dxRenderer.handleActionCancel(ids, xs, ys, timestampSec)
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -170,11 +320,15 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
|||||||
val xPointerDown = motionEvent.getX(indexPointerDown)
|
val xPointerDown = motionEvent.getX(indexPointerDown)
|
||||||
val y = motionEvent.getY(indexPointerDown)
|
val y = motionEvent.getY(indexPointerDown)
|
||||||
queueEvent {
|
queueEvent {
|
||||||
cocos2dxRenderer.handleActionDown(
|
sendNextEventTimestamp(timestamp)
|
||||||
idPointerDown,
|
|
||||||
xPointerDown,
|
if (controllerEventsEnabled)
|
||||||
y
|
GeodeUtils.internalTouchEvent(
|
||||||
)
|
timestamp, motionEvent.deviceId, motionEvent.source,
|
||||||
|
GeodeUtils.TOUCH_TYPE_BEGIN, intArrayOf(idPointerDown), floatArrayOf(xPointerDown), floatArrayOf(y)
|
||||||
|
)
|
||||||
|
else
|
||||||
|
cocos2dxRenderer.handleActionDown(idPointerDown, xPointerDown, y, timestampSec)
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -184,11 +338,15 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
|||||||
val xPointerUp = motionEvent.getX(indexPointUp)
|
val xPointerUp = motionEvent.getX(indexPointUp)
|
||||||
val y2 = motionEvent.getY(indexPointUp)
|
val y2 = motionEvent.getY(indexPointUp)
|
||||||
queueEvent {
|
queueEvent {
|
||||||
cocos2dxRenderer.handleActionUp(
|
sendNextEventTimestamp(timestamp)
|
||||||
idPointerUp,
|
|
||||||
xPointerUp,
|
if (controllerEventsEnabled)
|
||||||
y2
|
GeodeUtils.internalTouchEvent(
|
||||||
)
|
timestamp, motionEvent.deviceId, motionEvent.source,
|
||||||
|
GeodeUtils.TOUCH_TYPE_END, intArrayOf(idPointerUp), floatArrayOf(xPointerUp), floatArrayOf(y2)
|
||||||
|
)
|
||||||
|
else
|
||||||
|
cocos2dxRenderer.handleActionUp(idPointerUp, xPointerUp, y2, timestampSec)
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -205,9 +363,106 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
|||||||
if (!isInEditMode) {
|
if (!isInEditMode) {
|
||||||
cocos2dxRenderer.setScreenWidthAndHeight(newSurfaceWidth, newSurfaceHeight)
|
cocos2dxRenderer.setScreenWidthAndHeight(newSurfaceWidth, newSurfaceHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (supportsResizeEvents) {
|
||||||
|
queueEvent {
|
||||||
|
GeodeUtils.resizeSurface(newSurfaceWidth, newSurfaceHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onKeyDown(keyCode: Int, keyEvent: KeyEvent): Boolean {
|
private fun queueKeyboardEvent(keyCode: Int, event: KeyEvent, down: Boolean): Boolean {
|
||||||
|
val keypadCode = keyCode == KeyEvent.KEYCODE_MENU || keyCode == KeyEvent.KEYCODE_BACK
|
||||||
|
if (event.isSystem && !keypadCode) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keypadCode && BaseRobTopActivity.blockBackButton) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
queueEvent {
|
||||||
|
sendNextEventTimestamp(event.eventTime * MS_TO_NS)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useKeyboardEvents) {
|
||||||
|
if (down) {
|
||||||
|
queueEvent {
|
||||||
|
GeodeUtils.nativeKeyDown(keyCode, event.modifiers, event.repeatCount != 0)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
queueEvent {
|
||||||
|
GeodeUtils.nativeKeyUp(keyCode, event.modifiers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (controllerEventsEnabled) {
|
||||||
|
queueEvent {
|
||||||
|
GeodeUtils.internalKeyEvent(
|
||||||
|
event.eventTime * MS_TO_NS,
|
||||||
|
event.deviceId,
|
||||||
|
event.source,
|
||||||
|
event.keyCode,
|
||||||
|
event.modifiers,
|
||||||
|
down,
|
||||||
|
event.repeatCount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// geode events override native callbacks
|
||||||
|
if (useKeyboardEvents || controllerEventsEnabled) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sendNewCocosEvents) {
|
||||||
|
if (KeyEvent.isGamepadButton(keyCode)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyCode == KeyEvent.KEYCODE_ESCAPE && BaseRobTopActivity.blockBackButton) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// messy logic
|
||||||
|
val isRepeat = event.repeatCount > 0
|
||||||
|
if (keypadCode) {
|
||||||
|
if (down) {
|
||||||
|
if (isRepeat || BaseRobTopActivity.blockBackButton) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
queueEvent {
|
||||||
|
cocos2dxRenderer.handleKeyDown(keyCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val eventTimestamp = event.eventTime / 1000.0
|
||||||
|
|
||||||
|
val shiftPressed = event.isShiftPressed
|
||||||
|
val ctrlPressed = event.isCtrlPressed
|
||||||
|
val altPressed = event.isAltPressed
|
||||||
|
|
||||||
|
queueEvent {
|
||||||
|
cocos2dxRenderer.handleKeyboardEvent(
|
||||||
|
keyCode, down, isRepeat, eventTimestamp,
|
||||||
|
shiftPressed, ctrlPressed, altPressed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun legacyKeyDown(keyCode: Int, keyEvent: KeyEvent): Boolean {
|
||||||
return when (keyCode) {
|
return when (keyCode) {
|
||||||
KeyEvent.KEYCODE_BACK, KeyEvent.KEYCODE_MENU -> {
|
KeyEvent.KEYCODE_BACK, KeyEvent.KEYCODE_MENU -> {
|
||||||
if (keyEvent.repeatCount != 0 || BaseRobTopActivity.blockBackButton) {
|
if (keyEvent.repeatCount != 0 || BaseRobTopActivity.blockBackButton) {
|
||||||
@@ -220,6 +475,186 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun sendKeyBack() {
|
||||||
|
if (cocos2dxEditText?.isFocused == true) {
|
||||||
|
requestFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (BaseRobTopActivity.blockBackButton) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentTime = System.nanoTime()
|
||||||
|
queueEvent {
|
||||||
|
sendNextEventTimestamp(currentTime)
|
||||||
|
|
||||||
|
if (controllerEventsEnabled) {
|
||||||
|
GeodeUtils.internalKeyEvent(
|
||||||
|
currentTime,
|
||||||
|
-1,
|
||||||
|
InputDevice.SOURCE_ANY,
|
||||||
|
KeyEvent.KEYCODE_BACK,
|
||||||
|
0,
|
||||||
|
true,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
GeodeUtils.internalKeyEvent(
|
||||||
|
currentTime,
|
||||||
|
-1,
|
||||||
|
InputDevice.SOURCE_ANY,
|
||||||
|
KeyEvent.KEYCODE_BACK,
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useKeyboardEvents) {
|
||||||
|
GeodeUtils.nativeKeyDown(KeyEvent.KEYCODE_BACK, 0, false)
|
||||||
|
GeodeUtils.nativeKeyUp(KeyEvent.KEYCODE_BACK, 0)
|
||||||
|
} else if (!controllerEventsEnabled) {
|
||||||
|
cocos2dxRenderer.handleKeyDown(KeyEvent.KEYCODE_BACK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
|
||||||
|
if (!queueKeyboardEvent(keyCode, event, true)) {
|
||||||
|
return legacyKeyDown(keyCode, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean {
|
||||||
|
if (!queueKeyboardEvent(keyCode, event, false)) {
|
||||||
|
return super.onKeyUp(keyCode, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// i'm lazy.
|
||||||
|
// https://developer.android.com/develop/ui/views/touch-and-input/game-controllers/controller-input
|
||||||
|
private fun getCenteredAxis(
|
||||||
|
event: MotionEvent,
|
||||||
|
device: InputDevice,
|
||||||
|
axis: Int,
|
||||||
|
historyPos: Int
|
||||||
|
): Float {
|
||||||
|
device.getMotionRange(axis, event.source)?.apply {
|
||||||
|
val value: Float = if (historyPos < 0) {
|
||||||
|
event.getAxisValue(axis)
|
||||||
|
} else {
|
||||||
|
event.getHistoricalAxisValue(axis, historyPos)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (abs(value) > flat) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0.0f
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getJoystickMotion(event: MotionEvent, axisHorizontal: Int, axisVertical: Int): Pair<FloatArray, FloatArray> {
|
||||||
|
val positionChangesX = ArrayList<Float>()
|
||||||
|
val positionChangesY = ArrayList<Float>()
|
||||||
|
|
||||||
|
val inputDevice = event.device
|
||||||
|
|
||||||
|
(0 until event.historySize).forEach { i ->
|
||||||
|
val x = getCenteredAxis(event, inputDevice, axisHorizontal, i)
|
||||||
|
val y = getCenteredAxis(event, inputDevice, axisVertical, i)
|
||||||
|
positionChangesX.add(x)
|
||||||
|
positionChangesY.add(y)
|
||||||
|
}
|
||||||
|
|
||||||
|
val x = getCenteredAxis(event, inputDevice, axisHorizontal, -1)
|
||||||
|
val y = getCenteredAxis(event, inputDevice, axisVertical, -1)
|
||||||
|
positionChangesX.add(x)
|
||||||
|
positionChangesY.add(y)
|
||||||
|
|
||||||
|
return Pair(positionChangesX.toFloatArray(), positionChangesY.toFloatArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getJoystickMotion(event: MotionEvent, axis: Int): FloatArray {
|
||||||
|
val positionChanges = ArrayList<Float>()
|
||||||
|
|
||||||
|
val inputDevice = event.device
|
||||||
|
|
||||||
|
(0 until event.historySize).forEach { i ->
|
||||||
|
val x = getCenteredAxis(event, inputDevice, axis, i)
|
||||||
|
positionChanges.add(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
val x = getCenteredAxis(event, inputDevice, axis, -1)
|
||||||
|
positionChanges.add(x)
|
||||||
|
|
||||||
|
return positionChanges.toFloatArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onGenericMotionEvent(event: MotionEvent?): Boolean {
|
||||||
|
val hasGeodeEvents = useKeyboardEvents || controllerEventsEnabled
|
||||||
|
if (!hasGeodeEvents && !sendNewCocosEvents) {
|
||||||
|
return super.onGenericMotionEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event?.action == MotionEvent.ACTION_SCROLL) {
|
||||||
|
val mouseX = event.x
|
||||||
|
val mouseY = event.y
|
||||||
|
|
||||||
|
val scrollX = event.getAxisValue(MotionEvent.AXIS_HSCROLL)
|
||||||
|
val scrollY = event.getAxisValue(MotionEvent.AXIS_VSCROLL)
|
||||||
|
val timestamp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||||
|
event.eventTimeNanos else event.eventTime * MS_TO_NS
|
||||||
|
|
||||||
|
queueEvent {
|
||||||
|
sendNextEventTimestamp(timestamp)
|
||||||
|
|
||||||
|
if (useKeyboardEvents) {
|
||||||
|
GeodeUtils.nativeActionScroll(scrollX, scrollY)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (controllerEventsEnabled) {
|
||||||
|
GeodeUtils.internalScrollEvent(timestamp, event.deviceId, event.source, scrollX, scrollY, mouseX, mouseY)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasGeodeEvents && sendNewCocosEvents) {
|
||||||
|
cocos2dxRenderer.handleScroll(mouseX, mouseY, scrollY, scrollX)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (controllerEventsEnabled && event?.action == MotionEvent.ACTION_MOVE && event.source and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK) {
|
||||||
|
val (motionLeftX, motionLeftY) = getJoystickMotion(event, MotionEvent.AXIS_X, MotionEvent.AXIS_Y)
|
||||||
|
val (motionRightX, motionRightY) = getJoystickMotion(event, MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ)
|
||||||
|
val (motionDPadX, motionDPadY) = getJoystickMotion(event, MotionEvent.AXIS_HAT_X, MotionEvent.AXIS_HAT_Y)
|
||||||
|
|
||||||
|
val motionLTrigger = getJoystickMotion(event, MotionEvent.AXIS_LTRIGGER)
|
||||||
|
val motionRTrigger = getJoystickMotion(event, MotionEvent.AXIS_RTRIGGER)
|
||||||
|
|
||||||
|
// i'm sure this is great for performance
|
||||||
|
val timestamp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||||
|
event.eventTimeNanos else event.eventTime * MS_TO_NS
|
||||||
|
|
||||||
|
queueEvent {
|
||||||
|
GeodeUtils.internalJoystickEvent(
|
||||||
|
timestamp, event.deviceId, event.source,
|
||||||
|
motionLeftX, motionLeftY,
|
||||||
|
motionRightX, motionRightY,
|
||||||
|
motionDPadX, motionDPadY,
|
||||||
|
motionLTrigger, motionRTrigger
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.onGenericMotionEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
fun setCocos2dxRenderer(renderer: Cocos2dxRenderer) {
|
fun setCocos2dxRenderer(renderer: Cocos2dxRenderer) {
|
||||||
this.cocos2dxRenderer = renderer
|
this.cocos2dxRenderer = renderer
|
||||||
setRenderer(this.cocos2dxRenderer)
|
setRenderer(this.cocos2dxRenderer)
|
||||||
@@ -236,4 +671,40 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
|||||||
fun deleteBackward() {
|
fun deleteBackward() {
|
||||||
queueEvent { cocos2dxRenderer.handleDeleteBackward() }
|
queueEvent { cocos2dxRenderer.handleDeleteBackward() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun enableControllerEvents() {
|
||||||
|
if (controllerEventsEnabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
controllerEventsEnabled = true
|
||||||
|
|
||||||
|
val inputManager = context.getSystemService(Context.INPUT_SERVICE) as InputManager
|
||||||
|
inputManager.registerInputDeviceListener(InputListener, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disableControllerEvents() {
|
||||||
|
if (!controllerEventsEnabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
controllerEventsEnabled = true
|
||||||
|
|
||||||
|
val inputManager = context.getSystemService(Context.INPUT_SERVICE) as InputManager
|
||||||
|
inputManager.unregisterInputDeviceListener(InputListener)
|
||||||
|
}
|
||||||
|
|
||||||
|
object InputListener : InputManager.InputDeviceListener {
|
||||||
|
override fun onInputDeviceAdded(deviceId: Int) {
|
||||||
|
GeodeUtils.inputDeviceAdded(deviceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onInputDeviceChanged(deviceId: Int) {
|
||||||
|
GeodeUtils.inputDeviceChanged(deviceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onInputDeviceRemoved(deviceId: Int) {
|
||||||
|
GeodeUtils.inputDeviceRemoved(deviceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ package org.cocos2dx.lib
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.ApplicationInfo
|
import android.content.pm.ApplicationInfo
|
||||||
|
import android.content.res.AssetManager
|
||||||
import android.os.Process
|
import android.os.Process
|
||||||
import com.customRobTop.BaseRobTopActivity
|
import com.customRobTop.BaseRobTopActivity
|
||||||
import com.geode.launcher.utils.LaunchUtils
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
@@ -11,9 +12,9 @@ import com.geode.launcher.utils.LaunchUtils
|
|||||||
object Cocos2dxHelper {
|
object Cocos2dxHelper {
|
||||||
// private val sCocos2dMusic: Cocos2dxMusic? = null
|
// private val sCocos2dMusic: Cocos2dxMusic? = null
|
||||||
// private val sCocos2dSound: Cocos2dxSound? = null
|
// private val sCocos2dSound: Cocos2dxSound? = null
|
||||||
// private val sAssetManager: AssetManager? = null
|
private var sAssetManager: AssetManager? = null
|
||||||
// private val sCocos2dxAccelerometer: Cocos2dxAccelerometer? = null
|
private var sCocos2dxAccelerometer: Cocos2dxAccelerometer? = null
|
||||||
// private val sAccelerometerEnabled = false
|
private var sAccelerometerEnabled = false
|
||||||
private var packageName: String? = null
|
private var packageName: String? = null
|
||||||
private var fileDirectory: String? = null
|
private var fileDirectory: String? = null
|
||||||
|
|
||||||
@@ -36,6 +37,40 @@ object Cocos2dxHelper {
|
|||||||
Process.killProcess(Process.myPid())
|
Process.killProcess(Process.myPid())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun getAssetManager(): AssetManager? {
|
||||||
|
return sAssetManager
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun enableAccelerometer() {
|
||||||
|
sAccelerometerEnabled = true
|
||||||
|
sCocos2dxAccelerometer?.enable()
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun setAccelerometerInterval(interval: Float) {
|
||||||
|
sCocos2dxAccelerometer?.setInterval(interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun disableAccelerometer() {
|
||||||
|
sAccelerometerEnabled = false
|
||||||
|
sCocos2dxAccelerometer?.disable()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onResume() {
|
||||||
|
if (sAccelerometerEnabled) {
|
||||||
|
sCocos2dxAccelerometer?.enable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onPause() {
|
||||||
|
if (sAccelerometerEnabled) {
|
||||||
|
sCocos2dxAccelerometer?.disable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun getDPI(): Int {
|
fun getDPI(): Int {
|
||||||
return BaseRobTopActivity.me.get()?.resources?.configuration?.densityDpi ?: -1
|
return BaseRobTopActivity.me.get()?.resources?.configuration?.densityDpi ?: -1
|
||||||
@@ -52,14 +87,14 @@ object Cocos2dxHelper {
|
|||||||
this.cocos2dxHelperListener = cocos2dxHelperListener
|
this.cocos2dxHelperListener = cocos2dxHelperListener
|
||||||
packageName = applicationInfo.packageName
|
packageName = applicationInfo.packageName
|
||||||
fileDirectory = LaunchUtils.getSaveDirectory(context).absolutePath
|
fileDirectory = LaunchUtils.getSaveDirectory(context).absolutePath
|
||||||
// Cocos2dxHelper.sCocos2dxAccelerometer = Cocos2dxAccelerometer(pContext)
|
sCocos2dxAccelerometer = Cocos2dxAccelerometer(context)
|
||||||
// Cocos2dxHelper.sCocos2dMusic = Cocos2dxMusic(pContext)
|
// Cocos2dxHelper.sCocos2dMusic = Cocos2dxMusic(pContext)
|
||||||
// var simultaneousStreams: Int = Cocos2dxSound.MAX_SIMULTANEOUS_STREAMS_DEFAULT
|
// var simultaneousStreams: Int = Cocos2dxSound.MAX_SIMULTANEOUS_STREAMS_DEFAULT
|
||||||
// if (Cocos2dxHelper.getDeviceModel().indexOf("GT-I9100") !== -1) {
|
// if (Cocos2dxHelper.getDeviceModel().indexOf("GT-I9100") !== -1) {
|
||||||
// simultaneousStreams = Cocos2dxSound.MAX_SIMULTANEOUS_STREAMS_I9100
|
// simultaneousStreams = Cocos2dxSound.MAX_SIMULTANEOUS_STREAMS_I9100
|
||||||
// }
|
// }
|
||||||
// Cocos2dxHelper.sCocos2dSound = Cocos2dxSound(pContext, simultaneousStreams)
|
// Cocos2dxHelper.sCocos2dSound = Cocos2dxSound(pContext, simultaneousStreams)
|
||||||
// Cocos2dxHelper.sAssetManager = pContext.getAssets()
|
sAssetManager = context.assets
|
||||||
Cocos2dxBitmap.setContext(context)
|
Cocos2dxBitmap.setContext(context)
|
||||||
// Cocos2dxETCLoader.setContext(pContext)
|
// Cocos2dxETCLoader.setContext(pContext)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
package org.cocos2dx.lib
|
package org.cocos2dx.lib
|
||||||
|
|
||||||
import android.opengl.GLSurfaceView
|
import android.opengl.GLSurfaceView
|
||||||
|
import android.os.Build
|
||||||
import javax.microedition.khronos.egl.EGLConfig
|
import javax.microedition.khronos.egl.EGLConfig
|
||||||
import javax.microedition.khronos.opengles.GL10
|
import javax.microedition.khronos.opengles.GL10
|
||||||
|
|
||||||
|
private const val NANOSECONDS_PER_SECOND = 1000000000L
|
||||||
|
private const val NANOSECONDS_PER_MICROSECOND = 1000000L
|
||||||
|
|
||||||
@Suppress("unused", "KotlinJniMissingFunction")
|
@Suppress("unused", "KotlinJniMissingFunction")
|
||||||
class Cocos2dxRenderer : GLSurfaceView.Renderer {
|
class Cocos2dxRenderer(private var handler: Cocos2dxGLSurfaceView) : GLSurfaceView.Renderer {
|
||||||
companion object {
|
companion object {
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun setAnimationInterval(@Suppress("UNUSED_PARAMETER") animationInterval: Double) {
|
fun setAnimationInterval(@Suppress("UNUSED_PARAMETER") animationInterval: Double) {
|
||||||
@@ -24,33 +28,54 @@ class Cocos2dxRenderer : GLSurfaceView.Renderer {
|
|||||||
@JvmStatic
|
@JvmStatic
|
||||||
private external fun nativeKeyDown(keyCode: Int): Boolean
|
private external fun nativeKeyDown(keyCode: Int): Boolean
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
private external fun nativeKeyboardEvent(
|
||||||
|
keyCode: Int,
|
||||||
|
down: Boolean,
|
||||||
|
isRepeat: Boolean,
|
||||||
|
timestamp: Double,
|
||||||
|
shiftPressed: Boolean,
|
||||||
|
ctrlPressed: Boolean,
|
||||||
|
altPressed: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
private external fun nativeKeyboardWillShow(offset: Int)
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
private external fun nativeOnPause()
|
private external fun nativeOnPause()
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
private external fun nativeOnResume()
|
private external fun nativeOnResume()
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
private external fun nativeScroll(x: Float, y: Float, scrollY: Float, scrollX: Float)
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
private external fun nativeRender()
|
private external fun nativeRender()
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
private external fun nativeTextClosed()
|
private external fun nativeTextClosed()
|
||||||
|
|
||||||
|
// timestamp was added in 2.208, but the jni method has no required type signature.
|
||||||
|
// it doesn't hurt to unconditionally send one
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
private external fun nativeTouchesBegin(id: Int, x: Float, y: Float)
|
private external fun nativeTouchesBegin(id: Int, x: Float, y: Float, timestamp: Double)
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
private external fun nativeTouchesCancel(
|
private external fun nativeTouchesCancel(
|
||||||
ids: IntArray,
|
ids: IntArray,
|
||||||
xs: FloatArray,
|
xs: FloatArray,
|
||||||
ys: FloatArray
|
ys: FloatArray,
|
||||||
|
timestamp: Double,
|
||||||
)
|
)
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
private external fun nativeTouchesEnd(id: Int, x: Float, y: Float)
|
private external fun nativeTouchesEnd(id: Int, x: Float, y: Float, timestamp: Double)
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
private external fun nativeTouchesMove(ids: IntArray, xs: FloatArray, ys: FloatArray)
|
private external fun nativeTouchesMove(ids: IntArray, xs: FloatArray, ys: FloatArray, timestamp: Double)
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
private external fun nativeInit(width: Int, height: Int)
|
private external fun nativeInit(width: Int, height: Int)
|
||||||
@@ -59,6 +84,13 @@ class Cocos2dxRenderer : GLSurfaceView.Renderer {
|
|||||||
private var lastTickInNanoSeconds: Long = 0
|
private var lastTickInNanoSeconds: Long = 0
|
||||||
private var screenWidth = 0
|
private var screenWidth = 0
|
||||||
private var screenHeight = 0
|
private var screenHeight = 0
|
||||||
|
var sendResizeEvents = false
|
||||||
|
var setFrameRate = false
|
||||||
|
private var mAnimationInterval: Long? = null
|
||||||
|
|
||||||
|
fun limitFrameRate(rate: Int) {
|
||||||
|
mAnimationInterval = NANOSECONDS_PER_SECOND/rate
|
||||||
|
}
|
||||||
|
|
||||||
fun setScreenWidthAndHeight(surfaceWidth: Int, surfaceHeight: Int) {
|
fun setScreenWidthAndHeight(surfaceWidth: Int, surfaceHeight: Int) {
|
||||||
screenWidth = surfaceWidth
|
screenWidth = surfaceWidth
|
||||||
@@ -70,32 +102,66 @@ class Cocos2dxRenderer : GLSurfaceView.Renderer {
|
|||||||
lastTickInNanoSeconds = System.nanoTime()
|
lastTickInNanoSeconds = System.nanoTime()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSurfaceChanged(gl10: GL10?, width: Int, height: Int) {}
|
override fun onSurfaceChanged(gl10: GL10?, width: Int, height: Int) {
|
||||||
|
if (setFrameRate && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
handler.updateRefreshRate()
|
||||||
|
}
|
||||||
|
|
||||||
|
println("renderer surfaceChanged: ${width}x${height}@${handler.display.refreshRate}fps")
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDrawFrame(gl: GL10?) {
|
override fun onDrawFrame(gl: GL10?) {
|
||||||
nativeRender()
|
val animationInterval = mAnimationInterval
|
||||||
|
if (animationInterval == null) {
|
||||||
|
nativeRender()
|
||||||
|
} else {
|
||||||
|
nativeRender()
|
||||||
|
|
||||||
|
val now = System.nanoTime()
|
||||||
|
val interval = now - this.lastTickInNanoSeconds
|
||||||
|
|
||||||
|
if (interval < animationInterval) {
|
||||||
|
try {
|
||||||
|
Thread.sleep((animationInterval - interval) / NANOSECONDS_PER_MICROSECOND)
|
||||||
|
} catch (e: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastTickInNanoSeconds = System.nanoTime()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleActionDown(id: Int, x: Float, y: Float) {
|
fun handleActionDown(id: Int, x: Float, y: Float, timestamp: Double) {
|
||||||
nativeTouchesBegin(id, x, y)
|
nativeTouchesBegin(id, x, y, timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleActionUp(id: Int, x: Float, y: Float) {
|
fun handleActionUp(id: Int, x: Float, y: Float, timestamp: Double) {
|
||||||
nativeTouchesEnd(id, x, y)
|
nativeTouchesEnd(id, x, y, timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleActionCancel(ids: IntArray, xs: FloatArray, ys: FloatArray) {
|
fun handleActionCancel(ids: IntArray, xs: FloatArray, ys: FloatArray, timestamp: Double) {
|
||||||
nativeTouchesCancel(ids, xs, ys)
|
nativeTouchesCancel(ids, xs, ys, timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleActionMove(ids: IntArray, xs: FloatArray, ys: FloatArray) {
|
fun handleActionMove(ids: IntArray, xs: FloatArray, ys: FloatArray, timestamp: Double) {
|
||||||
nativeTouchesMove(ids, xs, ys)
|
nativeTouchesMove(ids, xs, ys, timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleKeyDown(keyCode: Int) {
|
fun handleKeyDown(keyCode: Int) {
|
||||||
nativeKeyDown(keyCode)
|
nativeKeyDown(keyCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun handleKeyboardWillShow(offset: Int) {
|
||||||
|
nativeKeyboardWillShow(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleKeyboardEvent(keyCode: Int, down: Boolean, isRepeat: Boolean, timestamp: Double, shiftPressed: Boolean, ctrlPressed: Boolean, altPressed: Boolean) {
|
||||||
|
nativeKeyboardEvent(keyCode, down, isRepeat, timestamp, shiftPressed, ctrlPressed, altPressed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleScroll(x: Float, y: Float, scrollY: Float, scrollX: Float) {
|
||||||
|
nativeScroll(x, y, scrollY, scrollX)
|
||||||
|
}
|
||||||
|
|
||||||
fun handleOnPause() {
|
fun handleOnPause() {
|
||||||
nativeOnPause()
|
nativeOnPause()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import android.text.TextWatcher
|
|||||||
import android.widget.TextView.OnEditorActionListener
|
import android.widget.TextView.OnEditorActionListener
|
||||||
import android.text.Editable
|
import android.text.Editable
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
|
import android.view.inputmethod.EditorInfo
|
||||||
import android.view.inputmethod.InputMethodManager
|
import android.view.inputmethod.InputMethodManager
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
|
import com.customRobTop.JniToCpp
|
||||||
|
|
||||||
class Cocos2dxTextInputWrapper(private val cocos2dxGLSurfaceView: Cocos2dxGLSurfaceView) : TextWatcher, OnEditorActionListener {
|
class Cocos2dxTextInputWrapper(private val cocos2dxGLSurfaceView: Cocos2dxGLSurfaceView) : TextWatcher, OnEditorActionListener {
|
||||||
private var originText: String? = null
|
private var originText: String? = null
|
||||||
@@ -19,21 +21,33 @@ class Cocos2dxTextInputWrapper(private val cocos2dxGLSurfaceView: Cocos2dxGLSurf
|
|||||||
this.originText = originText
|
this.originText = originText
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun afterTextChanged(s: Editable) {
|
override fun afterTextChanged(editable: Editable) {
|
||||||
if (!isFullScreenEdit) {
|
if (isFullScreenEdit) {
|
||||||
var nModified = s.length - text!!.length
|
return
|
||||||
if (nModified > 0) {
|
|
||||||
cocos2dxGLSurfaceView.insertText(
|
|
||||||
s.subSequence(text!!.length, s.length).toString()
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
while (nModified < 0) {
|
|
||||||
cocos2dxGLSurfaceView.deleteBackward()
|
|
||||||
nModified++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
text = s.toString()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val cText = text ?: return
|
||||||
|
|
||||||
|
val str = editable.toString()
|
||||||
|
val unchanged = str.commonPrefixWith(cText)
|
||||||
|
|
||||||
|
var unchangedUntil = unchanged.length
|
||||||
|
if (unchangedUntil > 0 && unchangedUntil < str.length && Character.isLowSurrogate(str[unchangedUntil])) {
|
||||||
|
unchangedUntil--
|
||||||
|
}
|
||||||
|
|
||||||
|
val codePointLength = cText.codePointCount(unchangedUntil, cText.length)
|
||||||
|
repeat(codePointLength) {
|
||||||
|
cocos2dxGLSurfaceView.deleteBackward()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unchangedUntil < str.length) {
|
||||||
|
cocos2dxGLSurfaceView.insertText(
|
||||||
|
str.substring(unchangedUntil)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
text = str
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun beforeTextChanged(
|
override fun beforeTextChanged(
|
||||||
@@ -47,24 +61,45 @@ class Cocos2dxTextInputWrapper(private val cocos2dxGLSurfaceView: Cocos2dxGLSurf
|
|||||||
|
|
||||||
override fun onTextChanged(pCharSequence: CharSequence, start: Int, before: Int, count: Int) {}
|
override fun onTextChanged(pCharSequence: CharSequence, start: Int, before: Int, count: Int) {}
|
||||||
override fun onEditorAction(textView: TextView, actionID: Int, keyEvent: KeyEvent?): Boolean {
|
override fun onEditorAction(textView: TextView, actionID: Int, keyEvent: KeyEvent?): Boolean {
|
||||||
if (cocos2dxGLSurfaceView.cocos2dxEditText == textView && isFullScreenEdit) {
|
val fullscreenIme = cocos2dxGLSurfaceView.cocos2dxEditText == textView && isFullScreenEdit
|
||||||
for (i in originText!!.length downTo 1) {
|
if (fullscreenIme) {
|
||||||
cocos2dxGLSurfaceView.deleteBackward()
|
|
||||||
}
|
|
||||||
var text = textView.text.toString()
|
var text = textView.text.toString()
|
||||||
if (text.compareTo("") == 0) {
|
if (originText != text || !cocos2dxGLSurfaceView.sendNewCocosEvents) {
|
||||||
text = "\n"
|
if (cocos2dxGLSurfaceView.sendNewCocosEvents) {
|
||||||
|
cocos2dxGLSurfaceView.queueEvent {
|
||||||
|
JniToCpp.textInputCaretToEnd()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repeat(originText!!.length) {
|
||||||
|
cocos2dxGLSurfaceView.deleteBackward()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cocos2dxGLSurfaceView.sendNewCocosEvents) {
|
||||||
|
if (!text.endsWith('\n')) {
|
||||||
|
text += '\n'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.isNotEmpty()) {
|
||||||
|
cocos2dxGLSurfaceView.insertText(text)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (10 != text[text.length - 1].code) {
|
}
|
||||||
text += 10.toChar()
|
|
||||||
|
if (fullscreenIme || actionID == EditorInfo.IME_ACTION_DONE) {
|
||||||
|
if (cocos2dxGLSurfaceView.sendNewCocosEvents) {
|
||||||
|
cocos2dxGLSurfaceView.queueEvent {
|
||||||
|
JniToCpp.textInputDone()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
cocos2dxGLSurfaceView.insertText(text)
|
|
||||||
}
|
}
|
||||||
if (actionID != 6) {
|
|
||||||
return false
|
if (actionID == EditorInfo.IME_ACTION_DONE) {
|
||||||
|
cocos2dxGLSurfaceView.requestFocus()
|
||||||
|
Cocos2dxGLSurfaceView.closeIMEKeyboard()
|
||||||
}
|
}
|
||||||
cocos2dxGLSurfaceView.requestFocus()
|
|
||||||
Cocos2dxGLSurfaceView.closeIMEKeyboard()
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,8 +10,9 @@ object Cocos2dxTypefaces {
|
|||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
operator fun get(pContext: Context, pAssetName: String): Typeface? {
|
operator fun get(pContext: Context, pAssetName: String): Typeface? {
|
||||||
var typeface: Typeface?
|
val typeface: Typeface?
|
||||||
var typeface2: Typeface
|
val typeface2: Typeface
|
||||||
|
|
||||||
synchronized(Cocos2dxTypefaces::class.java) {
|
synchronized(Cocos2dxTypefaces::class.java) {
|
||||||
if (!typefaceCache.containsKey(pAssetName)) {
|
if (!typefaceCache.containsKey(pAssetName)) {
|
||||||
typeface2 = if (pAssetName.startsWith("/")) {
|
typeface2 = if (pAssetName.startsWith("/")) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import android.content.Intent
|
|||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.content.res.AssetManager
|
import android.content.res.AssetManager
|
||||||
|
import android.media.AudioDeviceCallback
|
||||||
import android.media.AudioDeviceInfo
|
import android.media.AudioDeviceInfo
|
||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
@@ -14,27 +15,45 @@ import android.os.Build
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import java.io.FileNotFoundException
|
import java.io.FileNotFoundException
|
||||||
|
|
||||||
|
|
||||||
// i never agreed to your licenses! take that
|
// i never agreed to your licenses! take that
|
||||||
@SuppressLint("StaticFieldLeak")
|
@SuppressLint("StaticFieldLeak")
|
||||||
object FMOD {
|
object FMOD {
|
||||||
private var gContext: Context? = null
|
private var gContext: Context? = null
|
||||||
|
private var gPluginAudioDeviceCallback: PluginAudioDeviceCallback? = null
|
||||||
private val gPluginBroadcastReceiver = PluginBroadcastReceiver()
|
private val gPluginBroadcastReceiver = PluginBroadcastReceiver()
|
||||||
|
|
||||||
external fun OutputAAudioHeadphonesChanged()
|
external fun OutputAAudioHeadphonesChanged()
|
||||||
|
external fun SetInputEnumerationChanged()
|
||||||
|
external fun SetOutputEnumerationChanged()
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun init(context: Context?) {
|
fun init(context: Context?) {
|
||||||
gContext = context
|
gContext = context
|
||||||
gContext?.registerReceiver(
|
if (context == null) {
|
||||||
gPluginBroadcastReceiver,
|
return
|
||||||
IntentFilter(Intent.ACTION_HEADSET_PLUG)
|
}
|
||||||
)
|
|
||||||
|
val intentFilter = IntentFilter(Intent.ACTION_HEADSET_PLUG)
|
||||||
|
if (Build.VERSION.SDK_INT >= 34) {
|
||||||
|
context.registerReceiver(gPluginBroadcastReceiver, intentFilter, Context.RECEIVER_NOT_EXPORTED)
|
||||||
|
} else {
|
||||||
|
context.registerReceiver(gPluginBroadcastReceiver, intentFilter)
|
||||||
|
}
|
||||||
|
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
|
val pluginAudioDeviceCallback = PluginAudioDeviceCallback(audioManager.getDevices(3))
|
||||||
|
gPluginAudioDeviceCallback = pluginAudioDeviceCallback
|
||||||
|
audioManager.registerAudioDeviceCallback(pluginAudioDeviceCallback, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun close() {
|
fun close() {
|
||||||
val context = gContext
|
val context = gContext
|
||||||
context?.unregisterReceiver(gPluginBroadcastReceiver)
|
if (context != null) {
|
||||||
|
context.unregisterReceiver(gPluginBroadcastReceiver)
|
||||||
|
(context.getSystemService(Context.AUDIO_SERVICE) as AudioManager)
|
||||||
|
.unregisterAudioDeviceCallback(gPluginAudioDeviceCallback)
|
||||||
|
}
|
||||||
gContext = null
|
gContext = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +128,12 @@ object FMOD {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun getAudioDevices(i: Int): Array<AudioDeviceInfo?>? {
|
||||||
|
val context = gContext ?: return emptyArray()
|
||||||
|
return (context.getSystemService(Context.AUDIO_SERVICE) as AudioManager).getDevices(i)
|
||||||
|
}
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun fileDescriptorFromUri(str: String?): Int {
|
fun fileDescriptorFromUri(str: String?): Int {
|
||||||
gContext?.apply {
|
gContext?.apply {
|
||||||
@@ -127,4 +152,82 @@ object FMOD {
|
|||||||
OutputAAudioHeadphonesChanged()
|
OutputAAudioHeadphonesChanged()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal class PluginAudioDeviceCallback : AudioDeviceCallback {
|
||||||
|
val deviceSet: HashSet<Int> = HashSet()
|
||||||
|
|
||||||
|
constructor(audioDeviceInfoArr: Array<out AudioDeviceInfo?>?) {
|
||||||
|
if (audioDeviceInfoArr != null) {
|
||||||
|
deviceSet.addAll(audioDeviceInfoArr.mapNotNull { it?.id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAudioDevicesAdded(audioDeviceInfoArr: Array<out AudioDeviceInfo?>?) {
|
||||||
|
var z: Boolean
|
||||||
|
var i = 0
|
||||||
|
if (audioDeviceInfoArr != null) {
|
||||||
|
var i2 = 0
|
||||||
|
z = false
|
||||||
|
while (i < audioDeviceInfoArr.size) {
|
||||||
|
if (!deviceSet.contains(audioDeviceInfoArr[i]!!.id)) {
|
||||||
|
if (audioDeviceInfoArr[i]!!.isSource) {
|
||||||
|
i2 = 1
|
||||||
|
}
|
||||||
|
if (audioDeviceInfoArr[i]!!.isSink) {
|
||||||
|
z = true
|
||||||
|
}
|
||||||
|
deviceSet.add(audioDeviceInfoArr[i]!!.id)
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
i = i2
|
||||||
|
} else {
|
||||||
|
z = false
|
||||||
|
}
|
||||||
|
if (i != 0) {
|
||||||
|
try {
|
||||||
|
SetInputEnumerationChanged()
|
||||||
|
} catch (_: UnsatisfiedLinkError) { }
|
||||||
|
}
|
||||||
|
if (z) {
|
||||||
|
try {
|
||||||
|
SetOutputEnumerationChanged()
|
||||||
|
} catch (_: UnsatisfiedLinkError) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAudioDevicesRemoved(audioDeviceInfoArr: Array<out AudioDeviceInfo?>?) {
|
||||||
|
var z: Boolean
|
||||||
|
var i = 0
|
||||||
|
if (audioDeviceInfoArr != null) {
|
||||||
|
var i2 = 0
|
||||||
|
z = false
|
||||||
|
while (i < audioDeviceInfoArr.size) {
|
||||||
|
if (deviceSet.contains(audioDeviceInfoArr[i]!!.id)) {
|
||||||
|
if (audioDeviceInfoArr[i]!!.isSource) {
|
||||||
|
i2 = 1
|
||||||
|
}
|
||||||
|
if (audioDeviceInfoArr[i]!!.isSink) {
|
||||||
|
z = true
|
||||||
|
}
|
||||||
|
deviceSet.remove(audioDeviceInfoArr[i]!!.id)
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
i = i2
|
||||||
|
} else {
|
||||||
|
z = false
|
||||||
|
}
|
||||||
|
if (i != 0) {
|
||||||
|
try {
|
||||||
|
SetInputEnumerationChanged()
|
||||||
|
} catch (_: UnsatisfiedLinkError) { }
|
||||||
|
}
|
||||||
|
if (z) {
|
||||||
|
try {
|
||||||
|
SetOutputEnumerationChanged()
|
||||||
|
} catch (_: UnsatisfiedLinkError) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -11,18 +11,24 @@ import java.io.IOException
|
|||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
|
|
||||||
class MediaCodec {
|
class MediaCodec {
|
||||||
private var channelCount = 0
|
// these variables require getX functions for ndk access
|
||||||
|
var channelCount = 0
|
||||||
|
private set
|
||||||
|
|
||||||
var mCodecPtr = 0L
|
var length = 0L
|
||||||
|
private set
|
||||||
|
|
||||||
|
var sampleRate = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
private var mCodecPtr = 0L
|
||||||
private var mCurrentOutputBufferIndex = -1
|
private var mCurrentOutputBufferIndex = -1
|
||||||
private var mDecoder: MediaCodec? = null
|
private var mDecoder: MediaCodec? = null
|
||||||
private var mExtractor: MediaExtractor? = null
|
private var mExtractor: MediaExtractor? = null
|
||||||
private var mInputBuffers: Array<ByteBuffer>? = null
|
private var mInputBuffers: Array<ByteBuffer>? = null
|
||||||
private var mInputFinished = false
|
private var mInputFinished = false
|
||||||
private var length = 0L
|
|
||||||
private var mOutputBuffers: Array<ByteBuffer>? = null
|
private var mOutputBuffers: Array<ByteBuffer>? = null
|
||||||
private var mOutputFinished = false
|
private var mOutputFinished = false
|
||||||
private var sampleRate = 0
|
|
||||||
|
|
||||||
fun init(codecPtr: Long): Boolean {
|
fun init(codecPtr: Long): Boolean {
|
||||||
mCodecPtr = codecPtr
|
mCodecPtr = codecPtr
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="1024"
|
||||||
|
android:viewportHeight="1024">
|
||||||
|
<group android:scaleX="0.6689189"
|
||||||
|
android:scaleY="0.6689189"
|
||||||
|
android:translateX="169.51352"
|
||||||
|
android:translateY="182.1982">
|
||||||
|
<path
|
||||||
|
android:pathData="M553.7,189.9L536.9,177.1C522.2,165.8 501.8,165.8 487.1,177.1L207.6,390.5C194,400.9 188.3,418.8 193.3,435.2L300.9,783.2C306.2,800.3 322.1,812.1 340.1,812.1H683.9C701.9,812.1 717.8,800.3 723.1,783.2L830.7,435.2C835.7,418.8 830,400.9 816.4,390.5L792,371.9L762,466.3L680,733C676.5,744.4 666,752.1 654.2,752.1H368C356.1,752.1 345.7,744.4 342.2,733L252.8,442.3C249.5,431.5 253.2,419.8 262.1,413L494.6,234.5C495.6,233.7 496.6,233.1 497.6,232.5L553.7,189.9Z"
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:fillAlpha="0.25"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M500.6,320.6C508.5,314.5 519.5,314.5 527.4,320.6L686,442.2C693.3,447.8 696.3,457.3 693.6,466.1L632.6,664.4C629.7,673.6 621.2,679.9 611.6,679.9H416.4C406.8,679.9 398.3,673.6 395.4,664.4L385.4,632H439.4C440,632.1 440.7,632.2 441.4,632.2H583.2C588,632.2 592.3,629 593.7,624.4L638,477.5C639.3,473.2 637.8,468.5 634.3,465.7L519.1,375.6C515.1,372.5 509.5,372.5 505.5,375.6L399.1,458.8L344.5,499.1L334.4,466.1C331.7,457.3 334.7,447.8 342,442.2L500.6,320.6Z"
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:fillAlpha="0.25"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M557.1,165.4L536.9,150C522.2,138.8 501.8,138.8 487.1,150L180.9,383.8C167.3,394.2 161.6,412.1 166.7,428.5L284.4,809.4C289.7,826.6 305.6,838.3 323.6,838.3H700.4C718.4,838.3 734.3,826.6 739.6,809.4L857.3,428.5C862.4,412.1 856.7,394.2 843.1,383.8L814.8,362.3L782.4,464.4L693.2,754.4C689.7,765.7 679.2,773.5 667.4,773.5H354.6C342.8,773.5 332.3,765.7 328.8,754.4L231.2,437C227.9,426.2 231.6,414.5 240.6,407.6L494.6,212.6C495.6,211.8 496.6,211.1 497.7,210.5L557.1,165.4Z"
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:fillColor="#ffffff" />
|
||||||
|
<path
|
||||||
|
android:pathData="M500.6,284.3C508.5,278.2 519.5,278.2 527.4,284.3L716.8,429.5C724.1,435.1 727.1,444.6 724.4,453.4L651.6,689.9C648.8,699.2 640.3,705.5 630.6,705.5H397.4C387.7,705.5 379.2,699.2 376.4,689.9L363.9,649.5H425.7C426.4,649.6 427.1,649.7 427.9,649.7H596.1C601,649.7 605.2,646.5 606.6,641.9L659.1,468C660.5,463.6 659,458.9 655.4,456.1L518.8,349.3C514.8,346.2 509.2,346.2 505.2,349.3L379.9,447.3L316.2,494.3L303.6,453.4C300.9,444.6 303.9,435.1 311.2,429.5L500.6,284.3Z"
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:fillColor="#ffffff" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="204dp"
|
||||||
|
android:height="205dp"
|
||||||
|
android:viewportWidth="204"
|
||||||
|
android:viewportHeight="205">
|
||||||
|
<path
|
||||||
|
android:pathData="M114.92,6.98L109,2.47C104.7,-0.82 98.72,-0.82 94.42,2.47L4.72,70.96C0.72,74.02 -0.95,79.25 0.54,84.06L35.03,195.65C36.59,200.69 41.24,204.12 46.51,204.12H156.91C162.18,204.12 166.83,200.69 168.38,195.65L202.88,84.06C204.37,79.25 202.7,74.02 198.7,70.96L190.43,64.65L180.92,94.56L154.79,179.53C153.77,182.85 150.71,185.12 147.23,185.12H55.6C52.13,185.12 49.06,182.85 48.04,179.53L19.44,86.54C18.47,83.38 19.57,79.95 22.19,77.94L96.6,20.81C96.9,20.58 97.21,20.38 97.53,20.19L114.92,6.98Z"
|
||||||
|
android:fillType="evenOdd">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:startX="65.53"
|
||||||
|
android:startY="18.28"
|
||||||
|
android:endX="150.22"
|
||||||
|
android:endY="187.89"
|
||||||
|
android:type="linear">
|
||||||
|
<item android:offset="0" android:color="#FFFFFDFF"/>
|
||||||
|
<item android:offset="0.08" android:color="#FFF2EAF5"/>
|
||||||
|
<item android:offset="0.12" android:color="#FFEDE5EF"/>
|
||||||
|
<item android:offset="0.31" android:color="#FFCDB5CD"/>
|
||||||
|
<item android:offset="0.49" android:color="#FFBA9BBC"/>
|
||||||
|
<item android:offset="1" android:color="#FF8D7ACF"/>
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:pathData="M98.37,41.8C100.69,40.03 103.9,40.03 106.22,41.8L161.71,84.35C163.85,85.98 164.74,88.78 163.95,91.36L142.61,160.64C141.78,163.35 139.28,165.19 136.45,165.19H68.14C65.31,165.19 62.81,163.35 61.98,160.64L58.33,148.8H76.42C76.63,148.84 76.85,148.87 77.07,148.87H126.35C127.77,148.87 129.03,147.93 129.44,146.57L144.82,95.62C145.2,94.35 144.76,92.97 143.72,92.15L103.69,60.85C102.53,59.94 100.89,59.94 99.72,60.85L63.01,89.56L44.33,103.33L40.65,91.36C39.85,88.78 40.75,85.98 42.88,84.35L98.37,41.8Z"
|
||||||
|
android:fillType="evenOdd">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:startX="102.3"
|
||||||
|
android:startY="-83.96"
|
||||||
|
android:endX="102.3"
|
||||||
|
android:endY="165.06"
|
||||||
|
android:type="linear">
|
||||||
|
<item android:offset="0.48" android:color="#FFFFFFFF"/>
|
||||||
|
<item android:offset="0.63" android:color="#FFF5E4C2"/>
|
||||||
|
<item android:offset="0.73" android:color="#FFE5C7AD"/>
|
||||||
|
<item android:offset="1" android:color="#FFB790A9"/>
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="204dp"
|
||||||
|
android:height="205dp"
|
||||||
|
android:viewportWidth="204"
|
||||||
|
android:viewportHeight="205">
|
||||||
|
<path
|
||||||
|
android:pathData="M114.92,6.98L109,2.47C104.7,-0.82 98.72,-0.82 94.42,2.47L4.72,70.96C0.72,74.02 -0.95,79.25 0.54,84.06L35.03,195.65C36.59,200.69 41.24,204.12 46.51,204.12H156.91C162.18,204.12 166.83,200.69 168.38,195.65L202.88,84.06C204.37,79.25 202.7,74.02 198.7,70.96L190.43,64.65L180.92,94.56L154.79,179.53C153.77,182.85 150.71,185.12 147.23,185.12H55.6C52.13,185.12 49.06,182.85 48.04,179.53L19.44,86.54C18.47,83.38 19.57,79.95 22.19,77.94L96.6,20.81C96.9,20.58 97.21,20.38 97.53,20.19L114.92,6.98Z"
|
||||||
|
android:fillType="evenOdd">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:startX="65.53"
|
||||||
|
android:startY="18.28"
|
||||||
|
android:endX="150.22"
|
||||||
|
android:endY="187.89"
|
||||||
|
android:type="linear">
|
||||||
|
<item android:offset="0.0" android:color="#fff5b11d"/>
|
||||||
|
<item android:offset="0.10" android:color="#fff0834d"/>
|
||||||
|
<item android:offset="0.24" android:color="#ffe85f6b"/>
|
||||||
|
<item android:offset="0.49" android:color="#ffc9659e"/>
|
||||||
|
<item android:offset="1" android:color="#ffb9588f"/>
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:pathData="M98.37,41.8C100.69,40.03 103.9,40.03 106.22,41.8L161.71,84.35C163.85,85.98 164.74,88.78 163.95,91.36L142.61,160.64C141.78,163.35 139.28,165.19 136.45,165.19H68.14C65.31,165.19 62.81,163.35 61.98,160.64L58.33,148.8H76.42C76.63,148.84 76.85,148.87 77.07,148.87H126.35C127.77,148.87 129.03,147.93 129.44,146.57L144.82,95.62C145.2,94.35 144.76,92.97 143.72,92.15L103.69,60.85C102.53,59.94 100.89,59.94 99.72,60.85L63.01,89.56L44.33,103.33L40.65,91.36C39.85,88.78 40.75,85.98 42.88,84.35L98.37,41.8Z"
|
||||||
|
android:fillType="evenOdd">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:startX="102.3"
|
||||||
|
android:startY="-83.96"
|
||||||
|
android:endX="102.3"
|
||||||
|
android:endY="165.06"
|
||||||
|
android:type="linear">
|
||||||
|
<item android:offset="0.48" android:color="#fff2ce00"/>
|
||||||
|
<item android:offset="0.63" android:color="#fff4b97e"/>
|
||||||
|
<item android:offset="0.73" android:color="#fff7b66a"/>
|
||||||
|
<item android:offset="1" android:color="#ffeb8fac"/>
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="96dp"
|
||||||
|
android:height="96dp"
|
||||||
|
android:viewportWidth="696"
|
||||||
|
android:viewportHeight="698"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:pathData="M393.08,24.42L372.88,9C358.19,-2.22 337.81,-2.22 323.12,9L16.94,242.81C3.28,253.24 -2.42,271.09 2.66,287.51L120.41,668.43C125.72,685.61 141.6,697.33 159.58,697.33H536.42C554.4,697.33 570.28,685.61 575.59,668.43L693.34,287.51C698.42,271.09 692.72,253.24 679.06,242.81L650.84,221.26L618.37,323.39L529.2,613.39C525.71,624.72 515.24,632.46 503.39,632.46H190.61C178.76,632.46 168.29,624.72 164.8,613.39L67.2,295.97C63.88,285.19 67.62,273.49 76.56,266.62L330.56,71.62C331.58,70.84 332.64,70.14 333.74,69.52L393.08,24.42Z"
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:fillColor="@android:color/white">
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:pathData="M336.61,143.26C344.51,137.21 355.49,137.21 363.39,143.26L552.8,288.49C560.1,294.09 563.14,303.63 560.44,312.42L487.62,548.92C484.78,558.15 476.25,564.45 466.59,564.45H233.41C223.75,564.45 215.22,558.15 212.38,548.92L199.93,508.5H261.68C262.39,508.65 263.13,508.72 263.89,508.72H432.11C436.96,508.72 441.24,505.55 442.64,500.9L495.14,326.98C496.45,322.64 494.96,317.93 491.39,315.14L354.78,208.3C350.8,205.19 345.2,205.19 341.22,208.3L215.93,306.29L152.15,353.31L139.56,312.42C136.86,303.63 139.9,294.09 147.2,288.49L336.61,143.26Z"
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:fillColor="@android:color/white">
|
||||||
|
</path>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="96dp"
|
||||||
|
android:height="96dp"
|
||||||
|
android:viewportWidth="696"
|
||||||
|
android:viewportHeight="698"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:pathData="M336.61,143.26C344.51,137.21 355.49,137.21 363.39,143.26L552.8,288.49C560.1,294.09 563.14,303.63 560.44,312.42L487.62,548.92C484.78,558.15 476.25,564.45 466.59,564.45H233.41C223.75,564.45 215.22,558.15 212.38,548.92L199.93,508.5H261.68C262.39,508.65 263.13,508.72 263.89,508.72H432.11C436.96,508.72 441.24,505.55 442.64,500.9L495.14,326.98C496.45,322.64 494.96,317.93 491.39,315.14L354.78,208.3C350.8,205.19 345.2,205.19 341.22,208.3L215.93,306.29L152.15,353.31L139.56,312.42C136.86,303.63 139.9,294.09 147.2,288.49L336.61,143.26Z"
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:fillColor="@android:color/white">
|
||||||
|
</path>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="96dp"
|
||||||
|
android:height="96dp"
|
||||||
|
android:viewportWidth="696"
|
||||||
|
android:viewportHeight="698"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:pathData="M393.08,24.42L372.88,9C358.19,-2.22 337.81,-2.22 323.12,9L16.94,242.81C3.28,253.24 -2.42,271.09 2.66,287.51L120.41,668.43C125.72,685.61 141.6,697.33 159.58,697.33H536.42C554.4,697.33 570.28,685.61 575.59,668.43L693.34,287.51C698.42,271.09 692.72,253.24 679.06,242.81L650.84,221.26L618.37,323.39L529.2,613.39C525.71,624.72 515.24,632.46 503.39,632.46H190.61C178.76,632.46 168.29,624.72 164.8,613.39L67.2,295.97C63.88,285.19 67.62,273.49 76.56,266.62L330.56,71.62C331.58,70.84 332.64,70.14 333.74,69.52L393.08,24.42Z"
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:fillColor="@android:color/white">
|
||||||
|
</path>
|
||||||
|
</vector>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,28 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="1366"
|
||||||
|
android:viewportHeight="1366">
|
||||||
|
<group>
|
||||||
|
<clip-path
|
||||||
|
android:pathData="M0,0h1366v1366h-1366z"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M0,0h1366v1366h-1366z"
|
||||||
|
android:fillColor="#F4D48E"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M598,683m-512,0a512,512 0,1 1,1024 0a512,512 0,1 1,-1024 0"
|
||||||
|
android:fillColor="#F4D48E"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M873.8,459.7C752.5,412.3 642.7,323 397.2,323C151.8,323 18.6,418.7 4.3,463.8C-10.1,508.8 4.7,562.4 -1.7,561.4L-25,878H1382L1373,631.1C1346.8,597.8 1260.8,569.9 1217.2,562.4C1173.6,554.9 996.8,507.8 873.8,459.7Z"
|
||||||
|
android:fillColor="#F4A47B"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M647.9,685.4C569.9,647.4 412.4,573 277.3,573C142.1,573 80.5,611.7 39.1,632.4C-2.4,653.2 -9.5,656.1 -14.9,657.4L-25,978.5L650,985L647.9,685.4Z"
|
||||||
|
android:fillColor="#E27880"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M1362.1,728.7C1327,696.7 1194.3,624.7 888.5,623C725.4,622.1 492.3,679.9 354.7,757.1C228.9,827.6 68.9,867 -5.2,873.5L-152,1140H1583L1362.1,728.7Z"
|
||||||
|
android:fillColor="#B9588F"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M1107,1027.2C885.5,1027.2 739,943.6 544.9,943.6C350.9,943.6 228.8,1031.7 186.1,1040.2L-8,1120.8V1385L1376,1379L1367,909C1361.5,929 1287.6,1027.2 1107,1027.2Z"
|
||||||
|
android:fillColor="#714A9A"/>
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="1366"
|
||||||
|
android:viewportHeight="1366">
|
||||||
|
<group>
|
||||||
|
<clip-path
|
||||||
|
android:pathData="M0,0h1366v1366h-1366z"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M0,0h1366v1366h-1366z"
|
||||||
|
android:fillColor="#9EF4C3"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M598,683m-512,0a512,512 0,1 1,1024 0a512,512 0,1 1,-1024 0"
|
||||||
|
android:fillColor="#9EF4C3"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M873.8,459.7C752.5,412.3 642.7,323 397.2,323C151.8,323 18.6,418.7 4.3,463.8C-10.1,508.8 4.7,562.4 -1.7,561.4L-25,878H1382L1373,631.1C1346.8,597.8 1260.8,569.9 1217.2,562.4C1173.6,554.9 996.8,507.8 873.8,459.7Z"
|
||||||
|
android:fillColor="#6DDBAD"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M647.9,685.4C569.9,647.4 412.4,573 277.3,573C142.1,573 80.5,611.7 39.1,632.4C-2.4,653.2 -9.5,656.1 -14.9,657.4L-25,978.5L650,985L647.9,685.4Z"
|
||||||
|
android:fillColor="#64C780"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M1362.1,728.7C1327,696.7 1194.3,624.7 888.5,623C725.4,622.1 492.3,679.9 354.7,757.1C228.9,827.6 68.9,867 -5.2,873.5L-152,1140H1583L1362.1,728.7Z"
|
||||||
|
android:fillColor="#46A47D"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M1107,1027.2C885.5,1027.2 739,943.6 544.9,943.6C350.9,943.6 228.8,1031.7 186.1,1040.2L-8,1120.8V1385L1376,1379L1367,909C1361.5,929 1287.6,1027.2 1107,1027.2Z"
|
||||||
|
android:fillColor="#417563"/>
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="1366"
|
||||||
|
android:viewportHeight="1366">
|
||||||
|
<group>
|
||||||
|
<clip-path
|
||||||
|
android:pathData="M0,0h1366v1366h-1366z"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M0,0h1366v1366h-1366z"
|
||||||
|
android:fillColor="#CC7C7C"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M598,683m-512,0a512,512 0,1 1,1024 0a512,512 0,1 1,-1024 0"
|
||||||
|
android:fillColor="#CC7C7C"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M873.8,459.7C752.5,412.3 642.7,323 397.2,323C151.8,323 18.6,418.7 4.3,463.8C-10.1,508.8 4.7,562.4 -1.7,561.4L-25,878H1382L1373,631.1C1346.8,597.8 1260.8,569.9 1217.2,562.4C1173.6,554.9 996.8,507.8 873.8,459.7Z"
|
||||||
|
android:fillColor="#C6B471"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M647.9,685.4C569.9,647.4 412.4,573 277.3,573C142.1,573 80.5,611.7 39.1,632.4C-2.4,653.2 -9.5,656.1 -14.9,657.4L-25,978.5L650,985L647.9,685.4Z"
|
||||||
|
android:fillColor="#59AF7F"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M1362.1,728.7C1327,696.7 1194.3,624.7 888.5,623C725.4,622.1 492.3,679.9 354.7,757.1C228.9,827.6 68.9,867 -5.2,873.5L-152,1140H1583L1362.1,728.7Z"
|
||||||
|
android:fillColor="#5F8CAB"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M1107,1027.2C885.5,1027.2 739,943.6 544.9,943.6C350.9,943.6 228.8,1031.7 186.1,1040.2L-8,1120.8V1385L1376,1379L1367,909C1361.5,929 1287.6,1027.2 1107,1027.2Z"
|
||||||
|
android:fillColor="#70558D"/>
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="1024"
|
||||||
|
android:viewportHeight="1024">
|
||||||
|
<path
|
||||||
|
android:pathData="M0,0h1024v1024h-1024z"
|
||||||
|
android:fillColor="#070066"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M512.5,521.1L915.2,638.7L1082,922.6L915.2,1206.4L512.5,1324L109.8,1206.4L-57,922.6L109.8,638.7L512.5,521.1Z"
|
||||||
|
android:fillColor="#6694C8"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M915.2,638.7L1082,922.6V638.7H915.2Z"
|
||||||
|
android:fillColor="#4268B8"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M109.8,638.7L512.5,521.1L109.8,397.2V638.7Z"
|
||||||
|
android:fillColor="#2D5EC7"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M-57,922.6L109.8,638.7H-57V922.6Z"
|
||||||
|
android:fillColor="#4268B8"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M512.5,521.1L915.2,638.7V397.2L512.5,521.1Z"
|
||||||
|
android:fillColor="#2D5EC7"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M-57,638.7H109.8V397.2L-57,638.7Z"
|
||||||
|
android:fillColor="#2248AA"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M109.8,397.2L512.5,521.1V194L109.8,397.2Z"
|
||||||
|
android:fillColor="#020F9D"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M512.5,521.1L915.2,397.2L512.5,194V521.1Z"
|
||||||
|
android:fillColor="#020F9D"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M915.2,638.7H1082L915.2,397.2V638.7Z"
|
||||||
|
android:fillColor="#2248AA"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="643"
|
||||||
|
android:viewportHeight="766">
|
||||||
|
<group android:scaleX="0.41038585"
|
||||||
|
android:scaleY="0.4888889"
|
||||||
|
android:translateX="191.0163"
|
||||||
|
android:translateY="195.75555">
|
||||||
|
<path
|
||||||
|
android:pathData="M299.94,33.78C313.01,25.85 329.41,25.85 342.48,33.78L370.9,51.03L268.17,116.49L268.23,116.51L92.68,223.61C84.64,228.51 79.74,237.25 79.74,246.66V509.84C79.74,519.26 84.64,527.99 92.68,532.89L306.22,663.17C314.85,668.44 325.71,668.44 334.34,663.17L547.89,532.89C555.92,527.99 560.82,519.26 560.82,509.85V246.66C560.82,246.2 560.81,245.74 560.78,245.28V166.26L599.75,189.9C612,197.34 619.48,210.63 619.48,224.96V540.81C619.48,555.14 612,568.42 599.75,575.86L342.48,731.98C329.41,739.91 313.01,739.91 299.94,731.98L42.66,575.86C30.41,568.42 22.93,555.14 22.93,540.81V224.96C22.93,210.63 30.41,197.34 42.66,189.9L299.94,33.78Z"
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:fillAlpha="0.15"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M311.75,173.17C318.78,168.88 327.62,168.88 334.65,173.17L481.88,262.87C488.43,266.86 492.43,273.98 492.43,281.66V463.16C492.43,470.84 488.43,477.96 481.88,481.95L334.65,571.65C327.62,575.94 318.78,575.94 311.75,571.65L164.52,481.95C157.97,477.96 153.97,470.84 153.97,463.16V414.95L208.24,448.54C208.52,448.75 208.8,448.95 209.1,449.13L315.66,515.36C319.22,517.57 323.72,517.57 327.28,515.36L433.84,449.13C437.07,447.13 439.03,443.59 439.03,439.79V305.9C439.03,302.09 437.07,298.56 433.84,296.55L327.28,230.33C323.72,228.12 319.22,228.12 315.66,230.33L209.1,296.55C208.62,296.85 208.17,297.18 207.75,297.55L153.97,331.28V281.66C153.97,273.98 157.97,266.86 164.52,262.87L311.75,173.17Z"
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:fillAlpha="0.15"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M299.94,5.95C313.01,-1.98 329.41,-1.98 342.48,5.95L374.73,25.52L264.09,96.01L264.15,96.03L74.11,211.97C66.07,216.88 61.17,225.61 61.17,235.02V520.77C61.17,530.18 66.07,538.92 74.11,543.82L306.15,685.38C314.78,690.65 325.64,690.65 334.27,685.38L566.31,543.82C574.34,538.92 579.25,530.18 579.25,520.77V235.02C579.25,234.53 579.23,234.05 579.2,233.56V149.6L622.68,175.99C634.93,183.42 642.41,196.71 642.41,211.04V554.72C642.41,569.05 634.93,582.34 622.68,589.78L342.48,759.82C329.41,767.75 313.01,767.75 299.94,759.82L19.73,589.78C7.48,582.34 0,569.05 0,554.72V211.04C0,196.71 7.48,183.42 19.73,175.99L299.94,5.95Z"
|
||||||
|
android:fillColor="#ffffff"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M311.75,142.59C318.78,138.31 327.62,138.31 334.65,142.59L507.79,248.08C514.34,252.08 518.34,259.2 518.34,266.87V479.95C518.34,487.62 514.34,494.74 507.79,498.74L334.65,604.23C327.62,608.51 318.78,608.51 311.75,604.23L138.61,498.74C132.06,494.74 128.06,487.62 128.06,479.95V422.46L189.93,460.76C190.22,460.98 190.52,461.19 190.84,461.39L315.4,538.8C318.95,541.01 323.46,541.01 327.01,538.8L451.58,461.39C454.8,459.38 456.77,455.85 456.77,452.04V295.77C456.77,291.97 454.8,288.44 451.58,286.43L327.01,209.02C323.46,206.81 318.95,206.81 315.4,209.02L190.84,286.43C190.32,286.76 189.83,287.12 189.37,287.52L128.06,325.98V266.87C128.06,259.2 132.06,252.08 138.61,248.08L311.75,142.59Z"
|
||||||
|
android:fillColor="#ffffff"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="643"
|
||||||
|
android:viewportHeight="766">
|
||||||
|
<group android:scaleX="0.41038585"
|
||||||
|
android:scaleY="0.4888889"
|
||||||
|
android:translateX="191.0163"
|
||||||
|
android:translateY="195.75555">
|
||||||
|
<path
|
||||||
|
android:pathData="M299.94,5.95C313.01,-1.98 329.41,-1.98 342.48,5.95L374.73,25.52L264.09,96.01L264.15,96.03L74.11,211.97C66.07,216.88 61.17,225.61 61.17,235.02V520.77C61.17,530.18 66.07,538.92 74.11,543.82L306.15,685.38C314.78,690.65 325.64,690.65 334.27,685.38L566.31,543.82C574.34,538.92 579.25,530.18 579.25,520.77V235.02C579.25,234.53 579.23,234.05 579.2,233.56V149.6L622.68,175.99C634.93,183.42 642.41,196.71 642.41,211.04V554.72C642.41,569.05 634.93,582.34 622.68,589.78L342.48,759.82C329.41,767.75 313.01,767.75 299.94,759.82L19.73,589.78C7.48,582.34 0,569.05 0,554.72V211.04C0,196.71 7.48,183.42 19.73,175.99L299.94,5.95Z"
|
||||||
|
android:fillColor="#ffffff"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M311.75,142.59C318.78,138.31 327.62,138.31 334.65,142.59L507.79,248.08C514.34,252.08 518.34,259.2 518.34,266.87V479.95C518.34,487.62 514.34,494.74 507.79,498.74L334.65,604.23C327.62,608.51 318.78,608.51 311.75,604.23L138.61,498.74C132.06,494.74 128.06,487.62 128.06,479.95V422.46L189.93,460.76C190.22,460.98 190.52,461.19 190.84,461.39L315.4,538.8C318.95,541.01 323.46,541.01 327.01,538.8L451.58,461.39C454.8,459.38 456.77,455.85 456.77,452.04V295.77C456.77,291.97 454.8,288.44 451.58,286.43L327.01,209.02C323.46,206.81 318.95,206.81 315.4,209.02L190.84,286.43C190.32,286.76 189.83,287.12 189.37,287.52L128.06,325.98V266.87C128.06,259.2 132.06,252.08 138.61,248.08L311.75,142.59Z"
|
||||||
|
android:fillColor="#ffffff"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="1366"
|
||||||
|
android:viewportHeight="1366">
|
||||||
|
<group>
|
||||||
|
<clip-path
|
||||||
|
android:pathData="M0,0h1366v1366h-1366z"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M0,0h1366v1366h-1366z"
|
||||||
|
android:fillColor="#65B8C2"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M598,683m-512,0a512,512 0,1 1,1024 0a512,512 0,1 1,-1024 0"
|
||||||
|
android:fillColor="#65B8C2"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M873.8,459.7C752.5,412.3 642.7,323 397.2,323C151.8,323 18.6,418.7 4.3,463.8C-10.1,508.8 4.7,562.4 -1.7,561.4L-25,878H1382L1373,631.1C1346.8,597.8 1260.8,569.9 1217.2,562.4C1173.6,554.9 996.8,507.8 873.8,459.7Z"
|
||||||
|
android:fillColor="#DEA5CD"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M647.9,685.4C569.9,647.4 412.4,573 277.3,573C142.1,573 80.5,611.7 39.1,632.4C-2.4,653.2 -9.5,656.1 -14.9,657.4L-25,978.5L650,985L647.9,685.4Z"
|
||||||
|
android:fillColor="#DEE8EA"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M1362.1,728.7C1327,696.7 1194.3,624.7 888.5,623C725.4,622.1 492.3,679.9 354.7,757.1C228.9,827.6 68.9,867 -5.2,873.5L-152,1140H1583L1362.1,728.7Z"
|
||||||
|
android:fillColor="#C485BD"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M1107,1027.2C885.5,1027.2 739,943.6 544.9,943.6C350.9,943.6 228.8,1031.7 186.1,1040.2L-8,1120.8V1385L1376,1379L1367,909C1361.5,929 1287.6,1027.2 1107,1027.2Z"
|
||||||
|
android:fillColor="#259CB4"/>
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M440,520L200,520L200,440L440,440L440,200L520,200L520,440L760,440L760,520L520,520L520,760L440,760L440,520Z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960"
|
||||||
|
android:tint="?attr/colorControlNormal"
|
||||||
|
android:autoMirrored="true">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M313,520L537,744L480,800L160,480L480,160L537,216L313,440L800,440L800,520L313,520Z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M480,760Q546,760 593,713Q640,666 640,600L640,440Q640,374 593,327Q546,280 480,280Q414,280 367,327Q320,374 320,440L320,600Q320,666 367,713Q414,760 480,760ZM400,640L560,640L560,560L400,560L400,640ZM400,480L560,480L560,400L400,400L400,480ZM480,520Q480,520 480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520ZM480,840Q415,840 359.5,808Q304,776 272,720L160,720L160,640L244,640Q241,620 240.5,600Q240,580 240,560L160,560L160,480L240,480Q240,460 240.5,440Q241,420 244,400L160,400L160,320L272,320Q286,297 303.5,277Q321,257 344,242L280,176L336,120L422,206Q450,197 479,197Q508,197 536,206L624,120L680,176L614,242Q637,257 655.5,276.5Q674,296 688,320L800,320L800,400L716,400Q719,420 719.5,440Q720,460 720,480L800,480L800,560L720,560Q720,580 719.5,600Q719,620 716,640L800,640L800,720L688,720Q656,776 600.5,808Q545,840 480,840Z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M382,720L154,492L211,435L382,606L749,239L806,296L382,720Z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M336,680L280,624L424,480L280,337L336,281L480,425L623,281L679,337L535,480L679,624L623,680L480,536L336,680Z"/>
|
||||||
|
</vector>
|
||||||
Loaded 100 of 150 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user