mirror of
https://github.com/geode-sdk/android-launcher.git
synced 2026-10-22 16:42:38 +00:00
Compare commits
374
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dd122631c | ||
|
|
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 |
No files matched your search
@@ -7,6 +7,15 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
config:
|
||||||
|
- name: Release
|
||||||
|
lower: release
|
||||||
|
|
||||||
|
- name: Debug
|
||||||
|
lower: debug
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -27,7 +36,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build project
|
- name: Build project
|
||||||
run: |
|
run: |
|
||||||
./gradlew app:assembleDebug \
|
./gradlew app:assemble${{ 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}}" \
|
||||||
@@ -37,8 +46,8 @@ jobs:
|
|||||||
id: rename_apk_universal
|
id: rename_apk_universal
|
||||||
run: |
|
run: |
|
||||||
SHORT_SHA=${GITHUB_SHA::7}
|
SHORT_SHA=${GITHUB_SHA::7}
|
||||||
APK_PATH=${{github.workspace}}/app/build/outputs/apk/debug/app-universal-debug.apk
|
APK_PATH=${{github.workspace}}/app/build/outputs/apk/${{ matrix.config.lower }}/app-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
|
||||||
|
|
||||||
@@ -46,15 +55,36 @@ jobs:
|
|||||||
id: rename_apk
|
id: rename_apk
|
||||||
run: |
|
run: |
|
||||||
SHORT_SHA=${GITHUB_SHA::7}
|
SHORT_SHA=${GITHUB_SHA::7}
|
||||||
APK_PATH=${{github.workspace}}/app/build/outputs/apk/debug/app-armeabi-v7a-debug.apk
|
APK_PATH=${{github.workspace}}/app/build/outputs/apk/${{ matrix.config.lower }}/app-armeabi-v7a-${{ matrix.config.lower }}.apk
|
||||||
OUT_PATH=${{github.workspace}}/geode-launcher-debug-android32-$SHORT_SHA.apk
|
OUT_PATH=${{github.workspace}}/geode-launcher-${{ matrix.config.lower }}-android32-$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 mappings
|
||||||
uses: actions/upload-artifact@v3
|
id: rename_mappings
|
||||||
|
if: ${{ matrix.config.lower == 'release' }}
|
||||||
|
run: |
|
||||||
|
SHORT_SHA=${GITHUB_SHA::7}
|
||||||
|
MAPPING_PATH=${{github.workspace}}/app/build/outputs/mapping/${{ matrix.config.lower }}/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@v4
|
||||||
|
if: ${{ matrix.config.lower == 'release' }}
|
||||||
with:
|
with:
|
||||||
name: geode-launcher-debug
|
name: geode-launcher-${{ matrix.config.lower }}
|
||||||
|
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@v4
|
||||||
|
if: ${{ matrix.config.lower == 'debug' }}
|
||||||
|
with:
|
||||||
|
name: geode-launcher-${{ matrix.config.lower }}
|
||||||
path: |
|
path: |
|
||||||
${{ steps.rename_apk_universal.outputs.path }}
|
${{ steps.rename_apk_universal.outputs.path }}
|
||||||
${{ steps.rename_apk.outputs.path }}
|
${{ steps.rename_apk.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,96 +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 5
|
|
||||||
versionName "1.0.0-beta.2"
|
|
||||||
|
|
||||||
vectorDrawables {
|
|
||||||
useSupportLibrary true
|
|
||||||
}
|
|
||||||
externalNativeBuild {
|
|
||||||
cmake {
|
|
||||||
arguments "-DUSE_TULIPHOOK:BOOL=OFF"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ndkConfig.abiFilters "arm64-v8a", "armeabi-v7a"
|
|
||||||
}
|
|
||||||
|
|
||||||
splits {
|
|
||||||
abi {
|
|
||||||
enable true
|
|
||||||
reset()
|
|
||||||
|
|
||||||
//noinspection ChromeOsAbiSupport (not my fault)
|
|
||||||
include "arm64-v8a", "armeabi-v7a"
|
|
||||||
|
|
||||||
universalApk true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,115 @@
|
|||||||
|
val composeBOM: String by rootProject.extra
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
extraWarnings.set(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.plugin.serialization")
|
||||||
|
id("org.jetbrains.kotlin.plugin.compose")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileSdk = 36
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.geode.launcher"
|
||||||
|
minSdk = 23
|
||||||
|
targetSdk = 36
|
||||||
|
versionCode = 26
|
||||||
|
versionName = "1.7.3"
|
||||||
|
|
||||||
|
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 = true
|
||||||
|
reset()
|
||||||
|
|
||||||
|
//noinspection ChromeOsAbiSupport. i'm sorry!
|
||||||
|
include("arm64-v8a", "armeabi-v7a")
|
||||||
|
|
||||||
|
isUniversalApk = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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_1_8
|
||||||
|
targetCompatibility = JavaVersion.VERSION_1_8
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
packaging {
|
||||||
|
resources {
|
||||||
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
externalNativeBuild {
|
||||||
|
cmake {
|
||||||
|
version = "4.0.0+"
|
||||||
|
path = file("src/main/cpp/CMakeLists.txt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
namespace = "com.geode.launcher"
|
||||||
|
ndkVersion = "29.0.14206865"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation (platform("androidx.compose:compose-bom:$composeBOM"))
|
||||||
|
implementation ("androidx.core:core-ktx:1.17.0")
|
||||||
|
implementation ("androidx.compose.ui:ui")
|
||||||
|
implementation ("androidx.compose.material3:material3")
|
||||||
|
implementation ("androidx.compose.ui:ui-tooling-preview")
|
||||||
|
implementation ("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
||||||
|
implementation ("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
|
||||||
|
implementation ("androidx.activity:activity-compose:1.12.4")
|
||||||
|
implementation ("androidx.activity:activity-ktx:1.12.4")
|
||||||
|
implementation ("androidx.appcompat:appcompat:1.7.1")
|
||||||
|
implementation ("androidx.documentfile:documentfile:1.1.0")
|
||||||
|
|
||||||
|
implementation ("com.squareup.okio:okio:3.16.4")
|
||||||
|
implementation ("com.squareup.okhttp3:okhttp:5.3.2")
|
||||||
|
implementation ("com.squareup.okhttp3:okhttp-coroutines:5.3.2")
|
||||||
|
|
||||||
|
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0")
|
||||||
|
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json-okio:1.10.0")
|
||||||
|
implementation ("org.jetbrains.kotlinx:kotlinx-datetime:0.7.1")
|
||||||
|
|
||||||
|
implementation ("com.mikepenz:multiplatform-markdown-renderer-android:0.39.2")
|
||||||
|
implementation ("com.mikepenz:multiplatform-markdown-renderer-m3:0.39.2")
|
||||||
|
implementation ("androidx.browser:browser:1.9.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
|
|
||||||
@@ -9,9 +9,20 @@
|
|||||||
<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.READ_MEDIA_IMAGES" />
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||||
|
<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 +30,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.GeodeLogViewActivity"
|
||||||
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="orientation|screenSize|smallestScreenSize"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
android:screenOrientation="landscape"
|
android:screenOrientation="landscape"
|
||||||
@@ -57,12 +146,81 @@
|
|||||||
|
|
||||||
<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=".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>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name=".UserDirectoryProvider"
|
||||||
|
android:authorities="com.geode.launcher.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" />
|
||||||
@@ -77,5 +235,10 @@
|
|||||||
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 4.1.2)
|
||||||
|
|
||||||
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,41 @@
|
|||||||
|
#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, 12> 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",
|
||||||
|
"__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,16 +2,14 @@ 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.*
|
import android.content.*
|
||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
import android.net.NetworkCapabilities
|
import android.net.NetworkCapabilities
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
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.core.net.toUri
|
||||||
|
import com.geode.launcher.R
|
||||||
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.lang.ref.WeakReference
|
import java.lang.ref.WeakReference
|
||||||
@@ -63,33 +61,40 @@ 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
|
@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) {
|
||||||
@@ -259,29 +264,4 @@ object BaseRobTopActivity {
|
|||||||
level = DeprecationLevel.HIDDEN
|
level = DeprecationLevel.HIDDEN
|
||||||
)
|
)
|
||||||
fun logEvent(event: String) {}
|
fun logEvent(event: String) {}
|
||||||
|
|
||||||
class ReceiverScreen : BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
|
||||||
when (intent.action) {
|
|
||||||
Intent.ACTION_SCREEN_ON -> {
|
|
||||||
Log.d("TAG", "ACTION_SCREEN_ON")
|
|
||||||
if (!(me.get()?.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager).isKeyguardLocked) {
|
|
||||||
shouldResumeSound = true
|
|
||||||
}
|
|
||||||
if (!isPaused && shouldResumeSound) {
|
|
||||||
resumeSound()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Intent.ACTION_SCREEN_OFF -> {
|
|
||||||
shouldResumeSound = false
|
|
||||||
}
|
|
||||||
Intent.ACTION_USER_PRESENT -> {
|
|
||||||
shouldResumeSound = true
|
|
||||||
if (!isPaused) {
|
|
||||||
resumeSound()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,137 +1,296 @@
|
|||||||
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 androidx.lifecycle.lifecycleScope
|
||||||
import com.customRobTop.BaseRobTopActivity
|
import com.customRobTop.BaseRobTopActivity
|
||||||
import com.customRobTop.JniToCpp
|
import com.customRobTop.JniToCpp
|
||||||
|
import com.geode.launcher.main.FullScreenLoadingIndicator
|
||||||
|
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 kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
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
|
||||||
import org.fmod.FMOD
|
import org.fmod.FMOD
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileInputStream
|
import java.io.IOException
|
||||||
import java.io.FileOutputStream
|
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 {
|
val rootLayout = setupContext()
|
||||||
tryLoadGame()
|
val loadingView = ComposeView(this).apply {
|
||||||
} catch (e: UnsatisfiedLinkError) {
|
setContent {
|
||||||
e.printStackTrace()
|
FullScreenLoadingIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// generates helpful information for use in debugging library load failures
|
if (rootLayout != null) {
|
||||||
val gdPackageInfo = packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0)
|
rootLayout.addView(loadingView)
|
||||||
val failureReason = LaunchUtils.diagnoseLoadErrors(this, gdPackageInfo)
|
} else {
|
||||||
|
setContentView(loadingView)
|
||||||
|
}
|
||||||
|
|
||||||
returnToMain(
|
lifecycleScope.launch {
|
||||||
getString(R.string.load_failed_link_error),
|
val error = preloadGame(rootLayout)
|
||||||
getString(R.string.load_failed_link_error_description, failureReason.name)
|
if (error != null) {
|
||||||
)
|
returnToMain(error)
|
||||||
|
}
|
||||||
|
|
||||||
return
|
rootLayout?.removeView(loadingView)
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
|
|
||||||
returnToMain(
|
|
||||||
getString(R.string.load_failed_generic_error),
|
|
||||||
getString(R.string.load_failed_generic_error_description, e.message ?: "UnknownException")
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun returnToMain(returnTitle: String? = null, returnMessage: String? = null) {
|
suspend fun preloadGame(rootLayout: FrameLayout?): LaunchUtils.FullLauncherError? = try {
|
||||||
|
createVersionFile()
|
||||||
|
val loadFailed = tryLoadGame()
|
||||||
|
|
||||||
|
val baseView = createView()
|
||||||
|
|
||||||
|
if (rootLayout != null) {
|
||||||
|
rootLayout.addView(baseView)
|
||||||
|
} else {
|
||||||
|
setContentView(baseView)
|
||||||
|
}
|
||||||
|
|
||||||
|
mGLSurfaceView?.manualBackEvents = true
|
||||||
|
|
||||||
|
setupNotificationView(baseView, loadFailed)
|
||||||
|
|
||||||
|
null
|
||||||
|
} catch (e: UnsatisfiedLinkError) {
|
||||||
|
Log.e("GeodeLauncher", "Library linkage failure", e)
|
||||||
|
|
||||||
|
// generates helpful information for use in debugging library load failures
|
||||||
|
val gdPackageInfo = packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0)
|
||||||
|
val abiMismatch = GamePackageUtils.detectAbiMismatch(this, gdPackageInfo, e)
|
||||||
|
|
||||||
|
val is64bit = LaunchUtils.is64bit
|
||||||
|
val errorMessage = when {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchUtils.FullLauncherError(
|
||||||
|
errorMessage,
|
||||||
|
e.message ?: "Unknown exception",
|
||||||
|
e.stackTraceToString()
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("GeodeLauncher", "Uncaught error during game load", e)
|
||||||
|
|
||||||
|
LaunchUtils.FullLauncherError(
|
||||||
|
LaunchUtils.LauncherError.GENERIC,
|
||||||
|
e.message ?: "Unknown Exception",
|
||||||
|
e.stackTraceToString()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupContext(): FrameLayout? {
|
||||||
|
Cocos2dxHelper.init(this, this)
|
||||||
|
|
||||||
|
GeodeUtils.setContext(this)
|
||||||
|
GeodeUtils.setCapabilityListener(this)
|
||||||
|
|
||||||
|
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||||
|
override fun handleOnBackPressed() {
|
||||||
|
mGLSurfaceView?.sendKeyBack()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
WindowInsets.CONSUMED
|
||||||
|
}
|
||||||
|
|
||||||
|
setContentView(frameLayout)
|
||||||
|
return frameLayout
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun createVersionFile() = runCatching {
|
||||||
|
val gameVersion = GamePackageUtils.getGameVersionCode(packageManager)
|
||||||
|
|
||||||
|
val versionPath = File(filesDir, "game_version.txt")
|
||||||
|
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
if (versionPath.exists()) {
|
||||||
|
versionPath.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
versionPath.writeText("$gameVersion")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun returnToMain(message: LaunchUtils.FullLauncherError? = null, ) {
|
||||||
val launchIntent = Intent(this, MainActivity::class.java).apply {
|
val launchIntent = Intent(this, MainActivity::class.java).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 (message != null) {
|
||||||
putExtra(Constants.LAUNCHER_KEY_RETURN_TITLE, returnTitle)
|
putExtra(LaunchUtils.LAUNCHER_KEY_RETURN_ERROR, message.error)
|
||||||
putExtra(Constants.LAUNCHER_KEY_RETURN_MESSAGE, returnMessage)
|
putExtra(LaunchUtils.LAUNCHER_KEY_RETURN_MESSAGE, message.returnMessage)
|
||||||
|
putExtra(LaunchUtils.LAUNCHER_KEY_RETURN_EXTENDED_MESSAGE, message.extendedMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
startActivity(launchIntent)
|
startActivity(launchIntent)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun tryLoadGame() {
|
private suspend fun tryLoadGame(): Boolean {
|
||||||
val gdPackageInfo = packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0)
|
val gdPackageInfo = packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0)
|
||||||
|
|
||||||
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)
|
|
||||||
GeodeUtils.setContext(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()) {
|
if (GamePackageUtils.getGameVersionCode(packageManager) >= 39L) {
|
||||||
loadTestingLibraries()
|
// 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()
|
||||||
|
|
||||||
setupPostLibraryLoad(gdPackageInfo)
|
setupPostLibraryLoad(gdPackageInfo)
|
||||||
|
|
||||||
if (!loadGeodeLibrary()) {
|
return try {
|
||||||
Log.w("GeodeLauncher", "could not load Geode object!")
|
loadGeodeLibrary()
|
||||||
|
false
|
||||||
|
} catch (e: UnsatisfiedLinkError) {
|
||||||
|
handleGeodeException(e)
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
handleGeodeException(e)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
@@ -139,34 +298,92 @@ 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 suspend 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 suspend 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")
|
||||||
|
|
||||||
|
DownloadUtils.copyFile(library, libraryCopy)
|
||||||
|
|
||||||
|
System.load(libraryCopy.path)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("UnsafeDynamicallyLoadedCode")
|
||||||
|
private suspend 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")
|
||||||
|
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
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 {
|
||||||
@@ -195,61 +412,78 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("UnsafeDynamicallyLoadedCode")
|
@SuppressLint("UnsafeDynamicallyLoadedCode")
|
||||||
private fun loadGeodeLibrary(): Boolean {
|
private suspend fun loadGeodeLibrary() {
|
||||||
// Load Geode if exists
|
// Load Geode if exists
|
||||||
// bundling the object with the application allows for nicer backtraces
|
// bundling the object with the application allows for nicer backtraces
|
||||||
try {
|
try {
|
||||||
// put libgeode.so in jniLibs/armeabi-v7a to get this
|
// put libgeode.so in jniLibs/armeabi-v7a to get this
|
||||||
System.loadLibrary("geode")
|
System.loadLibrary("geode")
|
||||||
return true
|
return
|
||||||
} catch (e: UnsatisfiedLinkError) {
|
} catch (e: UnsatisfiedLinkError) {
|
||||||
// but users may prefer it stored with data
|
// but users may prefer it stored with data
|
||||||
val geodeFilename = LaunchUtils.geodeFilename
|
val geodeFilename = LaunchUtils.geodeFilename
|
||||||
val geodePath = File(filesDir.path, "launcher/$geodeFilename")
|
val geodePath = File(filesDir.path, "launcher/$geodeFilename")
|
||||||
if (geodePath.exists()) {
|
if (geodePath.exists()) {
|
||||||
System.load(geodePath.path)
|
System.load(geodePath.path)
|
||||||
return true
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// you know zmx i have 0 clue what this does so im
|
// you know zmx i have 0 clue what this does so im
|
||||||
// just gonna like copy the binary from external
|
// just gonna like copy the binary from external
|
||||||
// also i get 20 million permission denied errors
|
// also i get 20 million permission denied errors
|
||||||
val externalGeodePath = LaunchUtils.getInstalledGeodePath(this) ?: return false
|
val externalGeodePath = LaunchUtils.getInstalledGeodePath(this)!!
|
||||||
|
|
||||||
val copiedPath = File(filesDir.path, "copied")
|
val copiedPath = File(filesDir, "copied")
|
||||||
if (copiedPath.exists()) {
|
if (copiedPath.exists()) {
|
||||||
copiedPath.deleteRecursively()
|
copiedPath.deleteRecursively()
|
||||||
}
|
}
|
||||||
copiedPath.mkdir()
|
copiedPath.mkdir()
|
||||||
|
|
||||||
val copiedGeodePath = File(copiedPath.path, "Geode.so")
|
val copiedGeodePath = File(copiedPath, "Geode.so")
|
||||||
|
|
||||||
if (externalGeodePath.exists()) {
|
if (externalGeodePath.exists()) {
|
||||||
DownloadUtils.copyFile(
|
DownloadUtils.copyFile(
|
||||||
FileInputStream(externalGeodePath),
|
externalGeodePath,
|
||||||
FileOutputStream(copiedGeodePath)
|
copiedGeodePath
|
||||||
)
|
)
|
||||||
|
|
||||||
if (copiedGeodePath.exists()) {
|
if (copiedGeodePath.exists()) {
|
||||||
try {
|
println("Loading Geode from ${externalGeodePath.name}")
|
||||||
println("Loading Geode from ${externalGeodePath.name}")
|
|
||||||
System.load(copiedGeodePath.path)
|
System.load(copiedGeodePath.path)
|
||||||
} catch (e: UnsatisfiedLinkError) {
|
return
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
throw e
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
@@ -258,16 +492,22 @@ 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
|
||||||
|
|
||||||
return frameLayout
|
return frameLayout
|
||||||
@@ -276,33 +516,76 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,6 +598,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
|
||||||
@@ -324,33 +616,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)
|
||||||
}
|
}
|
||||||
@@ -377,50 +642,126 @@ 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
|
||||||
|
}
|
||||||
|
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")
|
class EGLConfigChooser : GLSurfaceView.EGLConfigChooser {
|
||||||
private fun loadTestingLibraries() {
|
// this comes from EGL14, but is unavailable on EGL10
|
||||||
// clear data dir
|
// also EGL14 is incompatible with EGL10. so whatever
|
||||||
val testDirPath = File(filesDir.path + File.separator + "testlib" + File.separator)
|
private var EGL_OPENGL_ES2_BIT: Int = 0x04
|
||||||
if (testDirPath.exists()) {
|
|
||||||
testDirPath.deleteRecursively()
|
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]
|
||||||
}
|
}
|
||||||
testDirPath.mkdir()
|
|
||||||
|
|
||||||
val dir = LaunchUtils.getBaseDirectory(this)
|
private fun testConfig(egl: EGL10, display: EGLDisplay, attrib: ConfigValues): EGLConfig? {
|
||||||
val testingPath = File(dir, "test")
|
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
|
||||||
|
)
|
||||||
|
|
||||||
testingPath.walk().forEach {
|
val configs = arrayOfNulls<EGLConfig>(1)
|
||||||
if (it.isFile) {
|
val numConfigs = IntArray(1)
|
||||||
// welcome to the world of Android classloader permissions
|
|
||||||
val outputFile = File(testDirPath, it.name)
|
|
||||||
DownloadUtils.copyFile(
|
|
||||||
FileInputStream(it),
|
|
||||||
FileOutputStream(outputFile)
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
val res = egl.eglChooseConfig(display, attribList, configs, configs.size, numConfigs)
|
||||||
println("Loading test library ${outputFile.name}")
|
if (res && numConfigs[0] > 0 && configs[0] != null) {
|
||||||
System.load(outputFile.path)
|
return configs[0]
|
||||||
} catch (e: UnsatisfiedLinkError) {
|
}
|
||||||
e.printStackTrace()
|
|
||||||
|
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,612 +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.provider.DocumentsContract
|
|
||||||
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) {
|
|
||||||
// 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 (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,11 +3,13 @@ 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.LaunchUtils
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
@@ -38,7 +40,7 @@ class UserDirectoryProvider : DocumentsProvider() {
|
|||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -150,7 +152,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)
|
||||||
@@ -203,4 +205,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,79 @@
|
|||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
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 -> name != LaunchUtils.CRASH_INDICATOR_NAME && name != "last-pid"
|
||||||
|
} ?: return emptyList()
|
||||||
|
|
||||||
|
hasIndicator = File(crashDirectory, LaunchUtils.CRASH_INDICATOR_NAME).exists()
|
||||||
|
|
||||||
|
return children
|
||||||
|
.map {
|
||||||
|
CrashDump(
|
||||||
|
filename = it.name,
|
||||||
|
lastModified = Instant.fromEpochMilliseconds(it.lastModified())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.sortedByDescending { it.lastModified }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearIndicator() {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
File(baseDirectory, LaunchUtils.CRASH_INDICATOR_NAME).delete()
|
||||||
|
|
||||||
|
hasIndicator = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createIndicator() {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
File(baseDirectory, LaunchUtils.CRASH_INDICATOR_NAME).createNewFile()
|
||||||
|
|
||||||
|
hasIndicator = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clearAllFiles() {
|
||||||
|
super.clearAllFiles()
|
||||||
|
hasIndicator = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.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,412 @@
|
|||||||
|
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.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
|
||||||
|
|
||||||
|
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 onShareCrash(context: Context) {
|
||||||
|
val lastCrash = LaunchUtils.getLastCrash(context)
|
||||||
|
if (lastCrash == null) {
|
||||||
|
Toast.makeText(context, R.string.launcher_error_export_missing, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val baseDirectory = LaunchUtils.getBaseDirectory(context)
|
||||||
|
|
||||||
|
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 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 {
|
||||||
|
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 {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
val developerSettingsEnabled = remember {
|
||||||
|
PreferenceUtils.get(context).getBoolean(PreferenceUtils.Key.DEVELOPER_MODE)
|
||||||
|
}
|
||||||
|
if (developerSettingsEnabled) {
|
||||||
|
FilledTonalButton(onClick = { onShowLogs(context) }) {
|
||||||
|
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,209 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@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, enabled: Boolean = true, modifier: Modifier = Modifier, 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)
|
||||||
|
|
||||||
|
// perform a second delay to make the action more obvious
|
||||||
|
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
delay(viewConfiguration.longPressTimeoutMillis)
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = modifier.padding(8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
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.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
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.Surface
|
||||||
|
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
|
||||||
|
|
||||||
|
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 = 5000L,
|
||||||
|
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)
|
||||||
|
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalTheme provides theme) {
|
||||||
|
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
|
||||||
|
// 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: Long = 0L, displayLength: Long = 3000L, onClick: (() -> Unit)? = null, contents: @Composable () -> Unit) {
|
||||||
|
val state = remember {
|
||||||
|
MutableTransitionState(false).apply {
|
||||||
|
targetState = visibilityDelay <= 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(true) {
|
||||||
|
if (visibilityDelay > 0) {
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FullScreenLoadingIndicator() {
|
||||||
|
val theme = Theme.DARK
|
||||||
|
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalTheme provides theme) {
|
||||||
|
GeodeLauncherTheme(theme = theme, blackBackground = true, dynamicColor = !dynamicColorOption) {
|
||||||
|
Surface(modifier = Modifier.fillMaxSize()) {
|
||||||
|
var showProgress by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
delay(1000L)
|
||||||
|
showProgress = true
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(showProgress) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
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.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
|
||||||
|
}
|
||||||
|
|
||||||
|
// last launch crashed, so cancel
|
||||||
|
if (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 (GamePackageUtils.getGameVersionCode(packageManager) < Constants.SUPPORTED_VERSION_CODE_MIN) {
|
||||||
|
_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
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
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.os.Build
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ElevatedCard(modifier) {
|
||||||
|
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,249 @@
|
|||||||
|
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}.${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,227 @@
|
|||||||
|
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.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)
|
||||||
|
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) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 1.dp, horizontal = 8.dp)
|
||||||
|
.height(64.dp)
|
||||||
|
.then(modifier)
|
||||||
|
.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,287 @@
|
|||||||
|
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.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_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_copy_external_button),
|
||||||
|
description = LaunchUtils.getBaseDirectory(context).path,
|
||||||
|
onClick = { onOpenFolder(context) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,173 @@
|
|||||||
|
package com.geode.launcher.preferences
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
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.layout.safeDrawingPadding
|
||||||
|
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.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 androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.geode.launcher.R
|
||||||
|
import com.geode.launcher.log.GeodeLogsViewModel
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class GeodeLogViewActivity : 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun GeodeLogView(
|
||||||
|
onBackPressedDispatcher: OnBackPressedDispatcher?,
|
||||||
|
filename: String,
|
||||||
|
geodeLogsViewModel: GeodeLogsViewModel = viewModel(factory = GeodeLogsViewModel.Factory)
|
||||||
|
) {
|
||||||
|
var loadingFile by remember { mutableStateOf(true) }
|
||||||
|
var description by remember { mutableStateOf(emptyList<String>()) }
|
||||||
|
|
||||||
|
LaunchedEffect(filename) {
|
||||||
|
loadingFile = true
|
||||||
|
description = geodeLogsViewModel.getFileText(filename)
|
||||||
|
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(
|
||||||
|
filename,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
},
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
)
|
||||||
|
|
||||||
|
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,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)
|
||||||
|
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, GeodeLogViewActivity::class.java).run {
|
||||||
|
putExtra(GeodeLogViewActivity.EXTRA_LOG_VIEW_FILENAME, geodeLog.filename)
|
||||||
|
}
|
||||||
|
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,792 @@
|
|||||||
|
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.MainActivity
|
||||||
|
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 = Intent(context, MainActivity::class.java).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) {
|
||||||
|
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_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
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
OptionsLabel(
|
||||||
|
stringResource(R.string.preference_loader_version_name),
|
||||||
|
stringResource(
|
||||||
|
R.string.preference_loader_version_description,
|
||||||
|
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,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,24 @@
|
|||||||
|
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 launcherTitleStyle = TextStyle(
|
||||||
|
fontFamily = quicksandFamily,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
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,161 @@
|
|||||||
|
package com.geode.launcher.updater
|
||||||
|
|
||||||
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlin.time.ExperimentalTime
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Asset @OptIn(ExperimentalTime::class) constructor(
|
||||||
|
val url: String,
|
||||||
|
val id: Int,
|
||||||
|
val name: String,
|
||||||
|
val size: Int,
|
||||||
|
val createdAt: Instant,
|
||||||
|
val updatedAt: Instant,
|
||||||
|
val browserDownloadUrl: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Release @OptIn(ExperimentalTime::class) constructor(
|
||||||
|
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 LoaderVersion @OptIn(ExperimentalTime::class) constructor(
|
||||||
|
val tag: String,
|
||||||
|
val version: String,
|
||||||
|
val createdAt: Instant,
|
||||||
|
val commitHash: String,
|
||||||
|
val prerelease: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LoaderPayload<T>(
|
||||||
|
val payload: T?,
|
||||||
|
val error: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DownloadableAsset(val url: String, val filename: String, val size: Long? = null)
|
||||||
|
|
||||||
|
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?
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTime::class)
|
||||||
|
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 DownloadableAsset(
|
||||||
|
url = download.browserDownloadUrl,
|
||||||
|
filename = download.name,
|
||||||
|
size = download.size.toLong()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DownloadableLauncherRelease(val release: Release) : Downloadable() {
|
||||||
|
override fun getDescription(): String {
|
||||||
|
return release.tagName
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTime::class)
|
||||||
|
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.contains("android32")) == use32BitPlatform
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getDownload(): DownloadableAsset? {
|
||||||
|
val download = getGitHubDownload() ?: return null
|
||||||
|
return DownloadableAsset(
|
||||||
|
url = download.browserDownloadUrl,
|
||||||
|
filename = download.name,
|
||||||
|
size = download.size.toLong()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DownloadableLoaderRelease(private val version: LoaderVersion) : Downloadable() {
|
||||||
|
override fun getDescription(): String {
|
||||||
|
return version.tag
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTime::class)
|
||||||
|
override fun getDescriptor(): Long {
|
||||||
|
return version.createdAt.epochSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getDownload(): DownloadableAsset? {
|
||||||
|
val filename = "geode-${version.tag}-${LaunchUtils.platformName}.zip"
|
||||||
|
return DownloadableAsset(
|
||||||
|
url = "https://github.com/geode-sdk/geode/releases/download/${version.tag}/$filename",
|
||||||
|
filename = filename
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
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.buffer
|
||||||
|
import okio.sink
|
||||||
|
import okio.source
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileNotFoundException
|
||||||
|
import java.io.InterruptedIOException
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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 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 {
|
||||||
|
DownloadUtils.downloadStream(
|
||||||
|
httpClient,
|
||||||
|
download.url,
|
||||||
|
onResponse = { body ->
|
||||||
|
body.source().use { source ->
|
||||||
|
outputFile.sink().buffer().use { sink ->
|
||||||
|
sink.writeAll(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return Result.failure(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.success(outputFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun performUpdate(release: Downloadable) {
|
||||||
|
val releaseAsset = release.getDownload()
|
||||||
|
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)
|
||||||
|
|
||||||
|
val outputFile = getTempFile()
|
||||||
|
// clone the file instance as renameTo may move the original file
|
||||||
|
val tempFilePath = outputFile.path
|
||||||
|
|
||||||
|
val geodeFile = getGeodeOutputPath()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// tempFile should be in same path as geodeFile
|
||||||
|
val geodeParent = geodeFile.parentFile
|
||||||
|
if (geodeParent != null && !geodeParent.exists()) {
|
||||||
|
geodeParent.mkdirs()
|
||||||
|
}
|
||||||
|
|
||||||
|
DownloadUtils.downloadStream(
|
||||||
|
httpClient,
|
||||||
|
releaseAsset.url,
|
||||||
|
onProgress = { progress, outOf ->
|
||||||
|
_uiState.value = ReleaseManagerState.InDownload(progress, outOf)
|
||||||
|
},
|
||||||
|
onResponse = { body ->
|
||||||
|
DownloadUtils.extractFileFromZipStream(
|
||||||
|
body.byteStream(),
|
||||||
|
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)
|
||||||
|
runCatching {
|
||||||
|
if (tempFileClone.exists()) tempFileClone.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 false
|
||||||
|
|
||||||
|
val currentFileHash = computeFileHash(geodeFile)
|
||||||
|
|
||||||
|
return originalFileHash != currentFileHash
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getLatestLauncherUpdate(): DownloadableLauncherRelease? {
|
||||||
|
return try {
|
||||||
|
releaseRepository.getLatestLauncherRelease()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
sendError(e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun checkLauncherUpdate() {
|
||||||
|
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() {
|
||||||
|
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> {
|
||||||
|
val update = _availableLauncherUpdateDetails.value
|
||||||
|
?: getLatestLauncherUpdate()
|
||||||
|
?: return Result.failure(FileNotFoundException())
|
||||||
|
|
||||||
|
return downloadLauncherUpdate(update)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shouldUseCache(): Boolean {
|
||||||
|
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) {
|
||||||
|
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 =
|
||||||
|
file.source().buffer().use { it.readByteString().md5().hex() }
|
||||||
|
|
||||||
|
private fun getGeodeOutputPath(): File {
|
||||||
|
val geodeName = LaunchUtils.geodeFilename
|
||||||
|
val geodeDirectory = LaunchUtils.getBaseDirectory(applicationContext)
|
||||||
|
|
||||||
|
return File(geodeDirectory, geodeName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getTempFile(): File {
|
||||||
|
val geodeName = LaunchUtils.geodeFilename
|
||||||
|
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 alphabet = ('A'..'Z') + ('a'..'z') + ('0'..'9')
|
||||||
|
val suffix = buildString(8) {
|
||||||
|
repeat(8) { append(alphabet.random()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val tmpName = "tmp-$suffix.$geodeName"
|
||||||
|
|
||||||
|
val tempFile = File(geodeDirectory, tmpName)
|
||||||
|
|
||||||
|
return tempFile
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-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,7 @@ 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 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 +30,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 +53,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 +80,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
|
||||||
|
|
||||||
@@ -89,7 +91,7 @@ class ReleaseViewModel(private val application: Application): ViewModel() {
|
|||||||
fun runReleaseCheck(isManual: Boolean = false) {
|
fun runReleaseCheck(isManual: Boolean = false) {
|
||||||
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 = 40L
|
||||||
|
|
||||||
|
// 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,27 +2,31 @@ 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.Path.Companion.toOkioPath
|
||||||
|
import okio.Sink
|
||||||
import okio.Source
|
import okio.Source
|
||||||
import okio.buffer
|
import okio.buffer
|
||||||
|
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
|
||||||
@@ -31,8 +35,9 @@ object DownloadUtils {
|
|||||||
suspend fun downloadStream(
|
suspend fun downloadStream(
|
||||||
httpClient: OkHttpClient,
|
httpClient: OkHttpClient,
|
||||||
url: String,
|
url: String,
|
||||||
onProgress: ProgressCallback? = null
|
onProgress: ProgressCallback? = null,
|
||||||
): InputStream {
|
onResponse: suspend (ResponseBody) -> Unit,
|
||||||
|
) {
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url(url)
|
.url(url)
|
||||||
.build()
|
.build()
|
||||||
@@ -50,7 +55,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 +64,35 @@ object DownloadUtils {
|
|||||||
val progressClient = progressClientBuilder.build()
|
val progressClient = progressClientBuilder.build()
|
||||||
|
|
||||||
val call = progressClient.newCall(request)
|
val call = progressClient.newCall(request)
|
||||||
val response = call.executeCoroutine()
|
call.executeAsync().use { response ->
|
||||||
|
when (response.code) {
|
||||||
return response.body!!.byteStream()
|
200 -> onResponse(response.body)
|
||||||
}
|
else -> throw IOException("unexpected response ${response.code}")
|
||||||
|
|
||||||
suspend fun Call.executeCoroutine(): Response {
|
|
||||||
return suspendCancellableCoroutine { continuation ->
|
|
||||||
this.enqueue(object : Callback {
|
|
||||||
override fun onFailure(call: Call, e: IOException) {
|
|
||||||
if (continuation.isCancelled) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
continuation.resumeWithException(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResponse(call: Call, response: Response) {
|
|
||||||
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,22 +103,32 @@ 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,4 +172,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,196 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
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(Uri.parse("content://downloads/public_downloads"), 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,171 @@
|
|||||||
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 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,100 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromId(id: String) = when (id) {
|
||||||
|
"default" -> ApplicationIcon.DEFAULT
|
||||||
|
"geode" -> ApplicationIcon.GEODE
|
||||||
|
"pride" -> ApplicationIcon.PRIDE
|
||||||
|
"trans" -> ApplicationIcon.TRANS
|
||||||
|
else -> ApplicationIcon.DEFAULT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toId() = when (this) {
|
||||||
|
ApplicationIcon.PRIDE -> "pride"
|
||||||
|
ApplicationIcon.GEODE -> "geode"
|
||||||
|
ApplicationIcon.DEFAULT -> "default"
|
||||||
|
ApplicationIcon.TRANS -> "trans"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,91 +1,19 @@
|
|||||||
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.content.pm.ApplicationInfo
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Log
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.io.Serializable
|
||||||
|
|
||||||
object LaunchUtils {
|
object LaunchUtils {
|
||||||
enum class FailureReason {
|
|
||||||
NOT_FOUND, ABI_MISMATCH, UNKNOWN
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
fun diagnoseLoadErrors(context: Context, packageInfo: PackageInfo): FailureReason {
|
|
||||||
val abi = 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 metadata =
|
|
||||||
"Geometry Dash metadata:\nSplit sources: $isSplit\nExtracted libraries: $isExtracted\nLauncher architecture: $abi"
|
|
||||||
Log.i("GeodeLauncher", metadata)
|
|
||||||
|
|
||||||
// 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"
|
|
||||||
|
|
||||||
if (packageInfo.applicationInfo.nativeLibraryDir.contains(oppositeArchitecture)) {
|
|
||||||
return FailureReason.ABI_MISMATCH
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isExtracted) {
|
|
||||||
try {
|
|
||||||
context.assets.openNonAssetFd("lib/$oppositeArchitecture/$gdBinaryName")
|
|
||||||
return FailureReason.ABI_MISMATCH
|
|
||||||
} catch (_: Exception) {
|
|
||||||
// this is good, actually!
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// try fetching for its own libraries (only for uncompressed libraries)
|
|
||||||
if (!isExtracted) {
|
|
||||||
try {
|
|
||||||
context.assets.openNonAssetFd("lib/$abi/$gdBinaryName")
|
|
||||||
} catch (_: Exception) {
|
|
||||||
return FailureReason.NOT_FOUND
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return FailureReason.UNKNOWN
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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"
|
||||||
|
|
||||||
@@ -119,7 +47,7 @@ object LaunchUtils {
|
|||||||
/**
|
/**
|
||||||
* 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)
|
// 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
|
// also, is getting the first item the correct choice here?? what do they mean
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
@@ -130,6 +58,14 @@ object LaunchUtils {
|
|||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,16 +73,59 @@ object LaunchUtils {
|
|||||||
return File(getBaseDirectory(context), "save")
|
return File(getBaseDirectory(context), "save")
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("DiscouragedPrivateApi")
|
const val CRASH_INDICATOR_NAME = "lastSessionDidCrash"
|
||||||
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 getLastCrash(context: Context): File? {
|
||||||
|
val crashDirectory = getCrashDirectory(context)
|
||||||
|
if (!crashDirectory.exists()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val children = crashDirectory.listFiles {
|
||||||
|
// ignore indicator files (including old file)
|
||||||
|
_, name -> name != CRASH_INDICATOR_NAME && name != "last-pid"
|
||||||
|
}
|
||||||
|
|
||||||
|
return children?.maxByOrNull { it.lastModified() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun lastSessionCrashed(context: Context): Boolean {
|
||||||
|
val base = getCrashDirectory(context)
|
||||||
|
val crashIndicatorFile = File(base, CRASH_INDICATOR_NAME)
|
||||||
|
|
||||||
|
return crashIndicatorFile.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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class FullLauncherError(
|
||||||
|
val error: LauncherError,
|
||||||
|
val returnMessage: String,
|
||||||
|
val extendedMessage: String? = null,
|
||||||
|
) : Serializable
|
||||||
|
|
||||||
|
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,85 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun defaultValueForBooleanKey(key: Key): Boolean {
|
private fun defaultValueForBooleanKey(key: Key): Boolean {
|
||||||
return when (key) {
|
return when (key) {
|
||||||
Key.UPDATE_AUTOMATICALLY -> 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.RELEASE_CHANNEL -> "PreferenceReleaseChannel"
|
||||||
|
// Key.LAST_DISMISSED_UPDATE -> "PreferenceLastDismissedUpdate"
|
||||||
|
// Key.FORCE_HRR -> "PreferenceForceHighRefreshRate"
|
||||||
|
// Key.DISABLE_REDESIGN -> "PreferenceDisableRedesign"
|
||||||
|
// Key.CUSTOM_SYMBOL_LIST -> "PreferenceCustomSymbolList"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +214,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 +234,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 +258,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,7 +1,7 @@
|
|||||||
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
|
||||||
|
|||||||
@@ -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,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,29 @@
|
|||||||
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.util.Log
|
||||||
|
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.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
|
||||||
|
|
||||||
class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
||||||
companion object {
|
companion object {
|
||||||
@@ -34,25 +45,37 @@ 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 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()) {
|
||||||
@@ -103,21 +126,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 +181,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 +198,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 +245,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 +263,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
|
||||||
}
|
}
|
||||||
@@ -207,7 +290,7 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onKeyDown(keyCode: Int, keyEvent: KeyEvent): Boolean {
|
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 +303,224 @@ 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 (!useKeyboardEvents && !controllerEventsEnabled) {
|
||||||
|
return legacyKeyDown(keyCode, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
return when (keyCode) {
|
||||||
|
// ignore system keys
|
||||||
|
KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_MUTE -> {
|
||||||
|
super.onKeyDown(keyCode, event)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
if (BaseRobTopActivity.blockBackButton) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
queueEvent {
|
||||||
|
sendNextEventTimestamp(event.eventTime * MS_TO_NS)
|
||||||
|
|
||||||
|
if (useKeyboardEvents) {
|
||||||
|
GeodeUtils.nativeKeyDown(keyCode, event.modifiers, event.repeatCount != 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (controllerEventsEnabled) {
|
||||||
|
GeodeUtils.internalKeyEvent(event.eventTime * MS_TO_NS, event.deviceId, event.source, event.keyCode, event.modifiers, true, event.repeatCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean {
|
||||||
|
if (!useKeyboardEvents && !controllerEventsEnabled) {
|
||||||
|
return super.onKeyUp(keyCode, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
return when (keyCode) {
|
||||||
|
KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_MUTE -> {
|
||||||
|
super.onKeyUp(keyCode, event)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
if (event.repeatCount != 0 || BaseRobTopActivity.blockBackButton) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
queueEvent {
|
||||||
|
sendNextEventTimestamp(event.eventTime * MS_TO_NS)
|
||||||
|
|
||||||
|
if (useKeyboardEvents) {
|
||||||
|
GeodeUtils.nativeKeyUp(keyCode, event.modifiers)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (controllerEventsEnabled) {
|
||||||
|
GeodeUtils.internalKeyEvent(event.eventTime * MS_TO_NS, event.deviceId, event.source, event.keyCode, event.modifiers, false, event.repeatCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
if (!useKeyboardEvents && !controllerEventsEnabled) {
|
||||||
|
return super.onGenericMotionEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event?.action == MotionEvent.ACTION_SCROLL) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +537,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) {
|
||||||
@@ -36,21 +40,25 @@ class Cocos2dxRenderer : GLSurfaceView.Renderer {
|
|||||||
@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 +67,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,26 +85,48 @@ 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) {
|
||||||
|
|||||||
@@ -52,12 +52,10 @@ class Cocos2dxTextInputWrapper(private val cocos2dxGLSurfaceView: Cocos2dxGLSurf
|
|||||||
cocos2dxGLSurfaceView.deleteBackward()
|
cocos2dxGLSurfaceView.deleteBackward()
|
||||||
}
|
}
|
||||||
var text = textView.text.toString()
|
var text = textView.text.toString()
|
||||||
if (text.compareTo("") == 0) {
|
if (!text.endsWith('\n')) {
|
||||||
text = "\n"
|
text += '\n'
|
||||||
}
|
|
||||||
if (10 != text[text.length - 1].code) {
|
|
||||||
text += 10.toChar()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cocos2dxGLSurfaceView.insertText(text)
|
cocos2dxGLSurfaceView.insertText(text)
|
||||||
}
|
}
|
||||||
if (actionID != 6) {
|
if (actionID != 6) {
|
||||||
|
|||||||
@@ -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="1024"
|
||||||
|
android:viewportHeight="1024">
|
||||||
|
<group android:scaleX="0.75" android:scaleY="0.75" android:translateX="128" android:translateY="128">
|
||||||
|
<clip-path
|
||||||
|
android:pathData="M0,0h1024v1024h-1024z"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M0,0h1024v1024h-1024z"
|
||||||
|
android:fillColor="#F4D48E"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M512,512m-512,0a512,512 0,1 1,1024 0a512,512 0,1 1,-1024 0"
|
||||||
|
android:fillColor="#F4D48E"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M719.5,289C618,241.7 526,152.5 320.5,152.5C115,152.5 3.5,248 -8.5,293C-20.5,338 -8.2,391.5 -13.5,390.5L-33,706.5H1145L1137.5,460C1115.5,426.8 1043.5,399 1007,391.5C970.5,384 822.5,337 719.5,289Z"
|
||||||
|
android:fillColor="#F4A47B"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M561.5,514.5C488.5,476.5 341,402 214.5,402C88,402 30.3,440.7 -8.5,461.5C-47.3,482.3 -54,485.2 -59,486.5L-68.5,808L563.5,814.5L561.5,514.5Z"
|
||||||
|
android:fillColor="#E27880"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M1020.5,551C994,521.2 893.7,454.1 662.5,452.5C539.2,451.6 363,505.6 259,577.5C163.9,643.2 43,680 -13,686L-124,934.5H1187.5L1020.5,551Z"
|
||||||
|
android:fillColor="#B9588F"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M819.5,856C638,856 518,772.5 359,772.5C200,772.5 100,860.5 65,869L-94,949.5V1041.5L1177.5,1025.5L1032.5,738C1028,758 967.4,856 819.5,856Z"
|
||||||
|
android:fillColor="#714A9A"/>
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<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.75" android:scaleY="0.75" android:translateX="128" android:translateY="128">
|
||||||
|
<clip-path
|
||||||
|
android:pathData="M0,0h1024v1024h-1024z"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M0,0h1024v1024h-1024z"
|
||||||
|
android:fillColor="#CC7C7C"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M512,512m-512,0a512,512 0,1 1,1024 0a512,512 0,1 1,-1024 0"
|
||||||
|
android:fillColor="#CC7C7C"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M719.5,289C618,241.7 526,152.5 320.5,152.5C115,152.5 3.5,248 -8.5,293C-20.5,338 -8.2,391.5 -13.5,390.5L-33,706.5H1145L1137.5,460C1115.5,426.8 1043.5,399 1007,391.5C970.5,384 822.5,337 719.5,289Z"
|
||||||
|
android:fillColor="#C6B471"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M561.5,514.5C488.5,476.5 341,402 214.5,402C88,402 30.3,440.7 -8.5,461.5C-47.3,482.3 -54,485.2 -59,486.5L-68.5,808L563.5,814.5L561.5,514.5Z"
|
||||||
|
android:fillColor="#59AF7F"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M1020.5,551C994,521.2 893.7,454.1 662.5,452.5C539.2,451.6 363,505.6 259,577.5C163.9,643.2 43,680 -13,686L-124,934.5H1187.5L1020.5,551Z"
|
||||||
|
android:fillColor="#5F8CAB"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M819.5,856C638,856 518,772.5 359,772.5C200,772.5 100,860.5 65,869L-94,949.5V1041.5L1177.5,1025.5L1032.5,738C1028,758 967.4,856 819.5,856Z"
|
||||||
|
android:fillColor="#70558D"/>
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<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.75" android:scaleY="0.75" android:translateX="128" android:translateY="128">
|
||||||
|
<clip-path
|
||||||
|
android:pathData="M0,0h1024v1024h-1024z"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M0,0h1024v1024h-1024z"
|
||||||
|
android:fillColor="#65B8C2"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M512,512m-512,0a512,512 0,1 1,1024 0a512,512 0,1 1,-1024 0"
|
||||||
|
android:fillColor="#65B8C2"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M719.5,289C618,241.7 526,152.5 320.5,152.5C115,152.5 3.5,248 -8.5,293C-20.5,338 -8.2,391.5 -13.5,390.5L-33,706.5H1145L1137.5,460C1115.5,426.8 1043.5,399 1007,391.5C970.5,384 822.5,337 719.5,289Z"
|
||||||
|
android:fillColor="#DEA5CD"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M561.5,514.5C488.5,476.5 341,402 214.5,402C88,402 30.3,440.7 -8.5,461.5C-47.3,482.3 -54,485.2 -59,486.5L-68.5,808L563.5,814.5L561.5,514.5Z"
|
||||||
|
android:fillColor="#DEE8EA"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M1020.5,551C994,521.2 893.7,454.1 662.5,452.5C539.2,451.6 363,505.6 259,577.5C163.9,643.2 43,680 -13,686L-124,934.5H1187.5L1020.5,551Z"
|
||||||
|
android:fillColor="#C485BD"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M819.5,856C638,856 518,772.5 359,772.5C200,772.5 100,860.5 65,869L-94,949.5V1041.5L1177.5,1025.5L1032.5,738C1028,758 967.4,856 819.5,856Z"
|
||||||
|
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>
|
||||||
@@ -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="M260,800Q169,800 104.5,737Q40,674 40,583Q40,505 87,444Q134,383 210,366Q235,274 310,217Q385,160 480,160Q597,160 678.5,241.5Q760,323 760,440L760,440L760,440Q829,448 874.5,499.5Q920,551 920,620Q920,695 867.5,747.5Q815,800 740,800L260,800ZM260,720L740,720Q782,720 811,691Q840,662 840,620Q840,578 811,549Q782,520 740,520L680,520L680,440Q680,357 621.5,298.5Q563,240 480,240Q397,240 338.5,298.5Q280,357 280,440L280,440L260,440Q202,440 161,481Q120,522 120,580Q120,638 161,679Q202,720 260,720ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480ZM480,640Q497,640 508.5,628.5Q520,617 520,600Q520,583 508.5,571.5Q497,560 480,560Q463,560 451.5,571.5Q440,583 440,600Q440,617 451.5,628.5Q463,640 480,640ZM440,500L520,500L520,320L440,320L440,500Z"/>
|
||||||
|
</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="M260,800Q169,800 104.5,737Q40,674 40,583Q40,505 87,444Q134,383 210,366Q235,274 310,217Q385,160 480,160Q597,160 678.5,241.5Q760,323 760,440L760,440L760,440Q829,448 874.5,499.5Q920,551 920,620Q920,695 867.5,747.5Q815,800 740,800L520,800Q487,800 463.5,776.5Q440,753 440,720L440,514L376,576L320,520L480,360L640,520L584,576L520,514L520,720Q520,720 520,720Q520,720 520,720L740,720Q782,720 811,691Q840,662 840,620Q840,578 811,549Q782,520 740,520L680,520L680,440Q680,357 621.5,298.5Q563,240 480,240Q397,240 338.5,298.5Q280,357 280,440L260,440Q202,440 161,481Q120,522 120,580Q120,638 161,679Q202,720 260,720L360,720L360,800L260,800ZM480,520L480,520L480,520Q480,520 480,520Q480,520 480,520L480,520L480,520Q480,520 480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520L480,520L480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520L480,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="M360,720Q327,720 303.5,696.5Q280,673 280,640L280,160Q280,127 303.5,103.5Q327,80 360,80L720,80Q753,80 776.5,103.5Q800,127 800,160L800,640Q800,673 776.5,696.5Q753,720 720,720L360,720ZM360,640L720,640Q720,640 720,640Q720,640 720,640L720,160Q720,160 720,160Q720,160 720,160L360,160Q360,160 360,160Q360,160 360,160L360,640Q360,640 360,640Q360,640 360,640ZM200,880Q167,880 143.5,856.5Q120,833 120,800L120,240L200,240L200,800Q200,800 200,800Q200,800 200,800L640,800L640,880L200,880ZM360,640Q360,640 360,640Q360,640 360,640L360,160Q360,160 360,160Q360,160 360,160L360,160Q360,160 360,160Q360,160 360,160L360,640Q360,640 360,640Q360,640 360,640L360,640Z"/>
|
||||||
|
</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="M560,800L560,720L680,720Q697,720 708.5,708.5Q720,697 720,680L720,600Q720,562 742,531Q764,500 800,487L800,473Q764,460 742,429Q720,398 720,360L720,280Q720,263 708.5,251.5Q697,240 680,240L560,240L560,160L680,160Q730,160 765,195Q800,230 800,280L800,360Q800,377 811.5,388.5Q823,400 840,400L880,400L880,560L840,560Q823,560 811.5,571.5Q800,583 800,600L800,680Q800,730 765,765Q730,800 680,800L560,800ZM280,800Q230,800 195,765Q160,730 160,680L160,600Q160,583 148.5,571.5Q137,560 120,560L80,560L80,400L120,400Q137,400 148.5,388.5Q160,377 160,360L160,280Q160,230 195,195Q230,160 280,160L400,160L400,240L280,240Q263,240 251.5,251.5Q240,263 240,280L240,360Q240,398 218,429Q196,460 160,473L160,487Q196,500 218,531Q240,562 240,600L240,680Q240,697 251.5,708.5Q263,720 280,720L400,720L400,800L280,800Z"/>
|
||||||
|
</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="M280,840Q247,840 223.5,816.5Q200,793 200,760L200,240L160,240L160,160L360,160L360,120L600,120L600,160L800,160L800,240L760,240L760,760Q760,793 736.5,816.5Q713,840 680,840L280,840ZM680,240L280,240L280,760Q280,760 280,760Q280,760 280,760L680,760Q680,760 680,760Q680,760 680,760L680,240ZM360,680L440,680L440,320L360,320L360,680ZM520,680L600,680L600,320L520,320L520,680ZM280,240L280,240L280,760Q280,760 280,760Q280,760 280,760L280,760Q280,760 280,760Q280,760 280,760L280,240Z"/>
|
||||||
|
</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="M320,720L640,720L640,640L320,640L320,720ZM320,560L640,560L640,480L320,480L320,560ZM240,880Q207,880 183.5,856.5Q160,833 160,800L160,160Q160,127 183.5,103.5Q207,80 240,80L560,80L800,320L800,800Q800,833 776.5,856.5Q753,880 720,880L240,880ZM520,360L520,160L240,160Q240,160 240,160Q240,160 240,160L240,800Q240,800 240,800Q240,800 240,800L720,800Q720,800 720,800Q720,800 720,800L720,360L520,360ZM240,160L240,160L240,360L240,360L240,160L240,360L240,360L240,800Q240,800 240,800Q240,800 240,800L240,800Q240,800 240,800Q240,800 240,800L240,160Q240,160 240,160Q240,160 240,160Z"/>
|
||||||
|
</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="M400,720L400,640L560,640L560,720L400,720ZM240,520L240,440L720,440L720,520L240,520ZM120,320L120,240L840,240L840,320L120,320Z"/>
|
||||||
|
</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="M160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L400,160L480,240L800,240Q833,240 856.5,263.5Q880,287 880,320L880,720Q880,753 856.5,776.5Q833,800 800,800L160,800ZM160,720L800,720Q800,720 800,720Q800,720 800,720L800,320Q800,320 800,320Q800,320 800,320L447,320L367,240L160,240Q160,240 160,240Q160,240 160,240L160,720Q160,720 160,720Q160,720 160,720ZM160,720Q160,720 160,720Q160,720 160,720L160,240Q160,240 160,240Q160,240 160,240L160,240L160,320L160,320Q160,320 160,320Q160,320 160,320L160,720Q160,720 160,720Q160,720 160,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="M440,680L520,680L520,440L440,440L440,680ZM480,360Q497,360 508.5,348.5Q520,337 520,320Q520,303 508.5,291.5Q497,280 480,280Q463,280 451.5,291.5Q440,303 440,320Q440,337 451.5,348.5Q463,360 480,360ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z"/>
|
||||||
|
</vector>
|
||||||
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user