mirror of
https://github.com/geode-sdk/android-launcher.git
synced 2026-10-21 08:19:33 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1658072b1a | ||
|
|
b6e88b20cb | ||
|
|
f4af50b2cb | ||
|
|
00c47f4c70 | ||
|
|
11649a236a | ||
|
|
ff712196cc | ||
|
|
6c57f3f261 | ||
|
|
626f8d00d1 | ||
|
|
45ecc71749 | ||
|
|
3d60bd3d53 | ||
|
|
e40af4c49b | ||
|
|
e0a31694af | ||
|
|
c8994c2484 | ||
|
|
35c8428f08 | ||
|
|
bca761f26a | ||
|
|
79c06ca629 | ||
|
|
3194dff7ff | ||
|
|
9e86bbe355 | ||
|
|
eef9160970 | ||
|
|
3b4951def1 | ||
|
|
ef44d48ed0 | ||
|
|
aae2aa2290 | ||
|
|
0a62082519 | ||
|
|
758d5ad432 | ||
|
|
b3a776ce7c | ||
|
|
72f74fa084 | ||
|
|
0dba4f0606 | ||
|
|
2016aa4e90 | ||
|
|
38bc97b7b6 | ||
|
|
0a4f13789f | ||
|
|
3cb0998274 | ||
|
|
5d70cb484f | ||
|
|
a27e67b41e | ||
|
|
8bff33c013 | ||
|
|
c9c51f0032 | ||
|
|
82b6cf309f | ||
|
|
a7654cfc69 | ||
|
|
c1d136c0cc | ||
|
|
269f2fbd02 | ||
|
|
21c3374248 | ||
|
|
120f412986 | ||
|
|
633543f363 | ||
|
|
d6b4157cdc | ||
|
|
482e3bc5d0 | ||
|
|
15e24b566e | ||
|
|
0de077ec1d | ||
|
|
655587e1ee | ||
|
|
d3ad272ffc | ||
|
|
79632c9844 | ||
|
|
8f5b1b4218 | ||
|
|
23ed997640 | ||
|
|
acaff15d4d | ||
|
|
669ea87be0 | ||
|
|
b668e5032b | ||
|
|
4508abc155 | ||
|
|
38a994436f | ||
|
|
5ab71d6c62 | ||
|
|
3b88a32e8f | ||
|
|
0ceecbbc72 | ||
|
|
714f750f77 | ||
|
|
69188075a3 | ||
|
|
187d3f9819 | ||
|
|
559cedb0f0 | ||
|
|
232b0a52ec | ||
|
|
915c46f05d | ||
|
|
a6ceee55d8 | ||
|
|
f2697cfb3b | ||
|
|
1bb68349e5 | ||
|
|
a677cab912 | ||
|
|
5caa533a4a | ||
|
|
1a68cc5eb9 | ||
|
|
ce98734bb9 | ||
|
|
f17500ef7b | ||
|
|
ea3ed8415b | ||
|
|
41664e430c | ||
|
|
8b17e6591f | ||
|
|
1d47a54573 | ||
|
|
9307c74df4 | ||
|
|
df0cebaa89 | ||
|
|
7bab94bf40 | ||
|
|
6d5de931fc | ||
|
|
c404f977e5 | ||
|
|
3202851303 | ||
|
|
d1fdb26d44 | ||
|
|
4987aa00db | ||
|
|
97b1cc288f | ||
|
|
00367096e9 | ||
|
|
0a7a366056 | ||
|
|
60ce6ddee8 | ||
|
|
6c1cc68ad0 | ||
|
|
b4da272a35 | ||
|
|
4745a3d36c | ||
|
|
2bf633922e | ||
|
|
02c7399eea | ||
|
|
d8985e14cc | ||
|
|
c2125248b6 | ||
|
|
e6bac1e25a | ||
|
|
eab0de3a34 | ||
|
|
9b7d174704 | ||
|
|
4c0d9b88d2 | ||
|
|
1adae94859 | ||
|
|
5edd63f78f | ||
|
|
3a54c34580 | ||
|
|
f12f480a38 | ||
|
|
1d7cd9594d | ||
|
|
9d4c27ac92 | ||
|
|
2754cc4bb8 | ||
|
|
0d01f8c74b | ||
|
|
24dd71a96d | ||
|
|
1f1e0de523 | ||
|
|
d0715c1bd8 | ||
|
|
41a5f560a3 | ||
|
|
3c43d8be22 | ||
|
|
f3d36259a1 | ||
|
|
6007fee0b8 | ||
|
|
afad922deb | ||
|
|
4df6da6521 | ||
|
|
d9b0b74762 | ||
|
|
a28e6d96ab | ||
|
|
c085409cee | ||
|
|
76c97a8d71 | ||
|
|
36fcbb5c52 | ||
|
|
e47f044189 | ||
|
|
426a1a1ade | ||
|
|
e786599e73 | ||
|
|
b08e2b4a3f | ||
|
|
41f663d55e | ||
|
|
b7d3d3e730 | ||
|
|
92d876560a | ||
|
|
b69c915e51 | ||
|
|
2b05f68af9 | ||
|
|
47d216b486 | ||
|
|
b4dd1567c9 | ||
|
|
8b2544434d | ||
|
|
24b0e39658 | ||
|
|
f763534a72 | ||
|
|
3887c3fe60 | ||
|
|
53a67f3e43 | ||
|
|
150bac781b | ||
|
|
0455a19f18 | ||
|
|
3a6c731710 | ||
|
|
2dde48c8fc | ||
|
|
f52b90b259 | ||
|
|
fa69a649dd | ||
|
|
a16c76cd06 | ||
|
|
9b5d614d1a | ||
|
|
d6a6de8361 | ||
|
|
2797e52683 | ||
|
|
69b00fecf5 | ||
|
|
f0eddd1652 | ||
|
|
d7190fa928 | ||
|
|
8e8a56da2e | ||
|
|
b418e154da | ||
|
|
cd306a5b5e | ||
|
|
fb9a6d1912 | ||
|
|
e276e302ab | ||
|
|
d7c779fce3 | ||
|
|
1e0dcc91ec | ||
|
|
266055e6d5 | ||
|
|
ab8e3f83d2 | ||
|
|
a75d68e5e0 | ||
|
|
13191dc66f | ||
|
|
b7f378b947 | ||
|
|
25367d5650 | ||
|
|
aa51c201e5 | ||
|
|
0b92f4a318 | ||
|
|
7d75825d01 | ||
|
|
c77271f45c | ||
|
|
09bb28d66f | ||
|
|
157952b6f7 | ||
|
|
352ff63ab0 | ||
|
|
91fc804dee | ||
|
|
87808b56b3 | ||
|
|
2c2884c3b8 | ||
|
|
74e91e7dc1 | ||
|
|
e3ffbf311e | ||
|
|
5ad77bca57 | ||
|
|
3be0127e04 | ||
|
|
458c837aba | ||
|
|
67ca7707d8 | ||
|
|
776a4fa468 | ||
|
|
9528eef313 | ||
|
|
9827fe36c1 | ||
|
|
ce44270b7a | ||
|
|
34e324c54d | ||
|
|
986e33ce03 | ||
|
|
91c06bde8c | ||
|
|
a9d40a7594 | ||
|
|
bb6146be37 | ||
|
|
071cf40d42 | ||
|
|
19f47f138a | ||
|
|
c070b5cfe0 | ||
|
|
4782431bae | ||
|
|
b63073c135 | ||
|
|
c029fed37d | ||
|
|
dacabda792 | ||
|
|
824d4395c7 | ||
|
|
bd502b56c2 | ||
|
|
6991598fcb | ||
|
|
2cc8e7215a | ||
|
|
5e81d1c70e | ||
|
|
b03ff6b735 | ||
|
|
220c1b0dd6 | ||
|
|
0a4fb091f4 | ||
|
|
110f74400c | ||
|
|
eed8020229 | ||
|
|
ceb3d5b461 | ||
|
|
53cd339213 | ||
|
|
327e9b4d39 | ||
|
|
10a0670a47 | ||
|
|
500d3ba015 | ||
|
|
1236180425 | ||
|
|
129ee40c0d | ||
|
|
a687f8464e | ||
|
|
b409a200bb | ||
|
|
acb07db6f2 | ||
|
|
0ae65971fc | ||
|
|
c4bd4333ca | ||
|
|
f9e30e549a | ||
|
|
1583f259b9 | ||
|
|
c090754129 | ||
|
|
31227860f4 | ||
|
|
edc2428f48 | ||
|
|
6f3473b4ca | ||
|
|
f0af12f466 | ||
|
|
5bea87f98c | ||
|
|
8617d900ee | ||
|
|
1d91357cad | ||
|
|
1d495cb26e | ||
|
|
31b5a8f16e | ||
|
|
b1c8690872 | ||
|
|
c01470ac5a | ||
|
|
98d3b76e4b | ||
|
|
4a5e4ce85c | ||
|
|
d3a3ab57a1 | ||
|
|
86a79f8cda | ||
|
|
5fdf3127ae | ||
|
|
e9635160d1 | ||
|
|
4e6f99c745 | ||
|
|
1fd72ba8bc | ||
|
|
03e12bf701 | ||
|
|
4ae10dba7b | ||
|
|
cad559f1d9 | ||
|
|
b60c564173 | ||
|
|
7c21431efe | ||
|
|
d345f4ccf8 | ||
|
|
6b70d8c6d0 | ||
|
|
5f8e2b0ac3 | ||
|
|
fa7a8d96ea | ||
|
|
291237d4f5 | ||
|
|
d4e85e3055 | ||
|
|
78992316e5 | ||
|
|
70333e3b5f | ||
|
|
492e1ac11c | ||
|
|
3daf5c48f0 | ||
|
|
e9e250191c |
No files matched your search
@@ -12,17 +12,23 @@ jobs:
|
|||||||
config:
|
config:
|
||||||
- name: Release
|
- name: Release
|
||||||
lower: release
|
lower: release
|
||||||
|
flavor: standard
|
||||||
|
|
||||||
- name: Debug
|
- name: Debug
|
||||||
lower: debug
|
lower: debug
|
||||||
|
flavor: standard
|
||||||
|
|
||||||
|
- name: Release
|
||||||
|
lower: release
|
||||||
|
flavor: googlePlay
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Setup Java JDK
|
- name: Setup Java JDK
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||||
with:
|
with:
|
||||||
java-version: 21
|
java-version: 21
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
@@ -34,9 +40,19 @@ jobs:
|
|||||||
- name: Fix gradle permissions
|
- name: Fix gradle permissions
|
||||||
run: chmod +x ./gradlew
|
run: chmod +x ./gradlew
|
||||||
|
|
||||||
- name: Build project
|
- name: Build project (standard)
|
||||||
|
if: ${{ matrix.config.flavor == 'standard' }}
|
||||||
run: |
|
run: |
|
||||||
./gradlew app:assemble${{ matrix.config.name }} \
|
./gradlew app:assembleStandard${{ matrix.config.name }} \
|
||||||
|
-Pandroid.injected.signing.store.file="$HOME/release.keystore" \
|
||||||
|
-Pandroid.injected.signing.store.password="${{secrets.KEYSTORE_PASSWORD}}" \
|
||||||
|
-Pandroid.injected.signing.key.alias="${{secrets.KEY_ALIAS}}" \
|
||||||
|
-Pandroid.injected.signing.key.password="${{secrets.KEY_PASSWORD}}"
|
||||||
|
|
||||||
|
- name: Build project (Google Play)
|
||||||
|
if: ${{ matrix.config.flavor == 'googlePlay' }}
|
||||||
|
run: |
|
||||||
|
./gradlew app:bundleGooglePlayRelease -PdisableAbiSplits \
|
||||||
-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}}" \
|
||||||
@@ -44,26 +60,78 @@ jobs:
|
|||||||
|
|
||||||
- name: Rename universal apk
|
- name: Rename universal apk
|
||||||
id: rename_apk_universal
|
id: rename_apk_universal
|
||||||
|
if: ${{ matrix.config.flavor == 'standard' }}
|
||||||
run: |
|
run: |
|
||||||
SHORT_SHA=${GITHUB_SHA::7}
|
SHORT_SHA=${GITHUB_SHA::7}
|
||||||
APK_PATH=${{github.workspace}}/app/build/outputs/apk/${{ matrix.config.lower }}/app-universal-${{ matrix.config.lower }}.apk
|
APK_PATH=${{github.workspace}}/app/build/outputs/apk/standard/${{ matrix.config.lower }}/app-standard-universal-${{ matrix.config.lower }}.apk
|
||||||
OUT_PATH=${{github.workspace}}/geode-launcher-${{ matrix.config.lower }}-$SHORT_SHA.apk
|
OUT_PATH=${{github.workspace}}/geode-launcher-${{ matrix.config.lower }}-$SHORT_SHA.apk
|
||||||
mv $APK_PATH $OUT_PATH
|
mv $APK_PATH $OUT_PATH
|
||||||
echo "path=$OUT_PATH" >> $GITHUB_OUTPUT
|
echo "path=$OUT_PATH" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Rename apk
|
- name: Rename apk
|
||||||
id: rename_apk
|
id: rename_apk
|
||||||
|
if: ${{ matrix.config.flavor == 'standard' }}
|
||||||
run: |
|
run: |
|
||||||
SHORT_SHA=${GITHUB_SHA::7}
|
SHORT_SHA=${GITHUB_SHA::7}
|
||||||
APK_PATH=${{github.workspace}}/app/build/outputs/apk/${{ matrix.config.lower }}/app-armeabi-v7a-${{ matrix.config.lower }}.apk
|
APK_PATH=${{github.workspace}}/app/build/outputs/apk/standard/${{ matrix.config.lower }}/app-standard-armeabi-v7a-${{ matrix.config.lower }}.apk
|
||||||
OUT_PATH=${{github.workspace}}/geode-launcher-${{ matrix.config.lower }}-android32-$SHORT_SHA.apk
|
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@v4
|
id: rename_mappings
|
||||||
|
if: ${{ matrix.config.flavor == 'standard' && matrix.config.lower == 'release' }}
|
||||||
|
run: |
|
||||||
|
SHORT_SHA=${GITHUB_SHA::7}
|
||||||
|
MAPPING_PATH=${{github.workspace}}/app/build/outputs/mapping/standardRelease/mapping.txt
|
||||||
|
OUT_PATH=${{github.workspace}}/mapping-${{ matrix.config.lower }}-$SHORT_SHA.txt
|
||||||
|
mv $MAPPING_PATH $OUT_PATH
|
||||||
|
echo "path=$OUT_PATH" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Rename AAB
|
||||||
|
id: rename_aab
|
||||||
|
if: ${{ matrix.config.flavor == 'googlePlay' }}
|
||||||
|
run: |
|
||||||
|
SHORT_SHA=${GITHUB_SHA::7}
|
||||||
|
AAB_PATH=${{github.workspace}}/app/build/outputs/bundle/googlePlayRelease/app-googlePlay-release.aab
|
||||||
|
OUT_PATH=${{github.workspace}}/geode-launcher-${{ matrix.config.lower }}-$SHORT_SHA.aab
|
||||||
|
mv $AAB_PATH $OUT_PATH
|
||||||
|
echo "path=$OUT_PATH" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Rename AAB mappings
|
||||||
|
id: rename_aab_mappings
|
||||||
|
if: ${{ matrix.config.flavor == 'googlePlay' }}
|
||||||
|
run: |
|
||||||
|
SHORT_SHA=${GITHUB_SHA::7}
|
||||||
|
MAPPING_PATH=${{github.workspace}}/app/build/outputs/mapping/googlePlayRelease/mapping.txt
|
||||||
|
OUT_PATH=${{github.workspace}}/mapping-${{ matrix.config.lower }}-$SHORT_SHA.txt
|
||||||
|
mv $MAPPING_PATH $OUT_PATH
|
||||||
|
echo "path=$OUT_PATH" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Upload release build
|
||||||
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||||
|
if: ${{ matrix.config.flavor == 'standard' && matrix.config.lower == 'release' }}
|
||||||
with:
|
with:
|
||||||
name: geode-launcher-${{ matrix.config.lower }}
|
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 }}
|
||||||
|
${{ steps.rename_mappings.outputs.path }}
|
||||||
|
|
||||||
|
- name: Upload debug build
|
||||||
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||||
|
if: ${{ matrix.config.flavor == 'standard' && matrix.config.lower == 'debug' }}
|
||||||
|
with:
|
||||||
|
name: geode-launcher-${{ matrix.config.lower }}
|
||||||
|
path: |
|
||||||
|
${{ steps.rename_apk_universal.outputs.path }}
|
||||||
|
${{ steps.rename_apk.outputs.path }}
|
||||||
|
|
||||||
|
- name: Upload Google Play build
|
||||||
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||||
|
if: ${{ matrix.config.flavor == 'googlePlay' }}
|
||||||
|
with:
|
||||||
|
name: geode-launcher-google-play
|
||||||
|
path: |
|
||||||
|
${{ steps.rename_aab.outputs.path }}
|
||||||
|
${{ steps.rename_aab_mappings.outputs.path }}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
name: Update Website
|
name: Update Website
|
||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
# release:
|
||||||
types:
|
# types:
|
||||||
- released
|
# - released
|
||||||
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,18 @@
|
|||||||
# Geode Android Launcher
|
# Geode Android Launcher
|
||||||
|
|
||||||
Launches a vanilla copy of Geometry Dash with the Geode loader added.
|
Launches a vanilla copy of Geometry Dash with the Geode loader added.
|
||||||
|
|
||||||
|
## Shortcut Links
|
||||||
|
|
||||||
|
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 |
|
||||||
+298
-28
@@ -1,21 +1,255 @@
|
|||||||
val composeBOM: String by rootProject.extra
|
import groovy.json.JsonSlurper
|
||||||
|
import java.net.URI
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.util.zip.ZipInputStream
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
extraWarnings.set(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("org.jetbrains.kotlin.android")
|
|
||||||
id("org.jetbrains.kotlin.plugin.serialization")
|
id("org.jetbrains.kotlin.plugin.serialization")
|
||||||
id("org.jetbrains.kotlin.plugin.compose")
|
id("org.jetbrains.kotlin.plugin.compose")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val currentGeodeVersion = "5.11.2"
|
||||||
|
|
||||||
|
abstract class DownloadVersionMetadataTask : DefaultTask() {
|
||||||
|
@get:Input
|
||||||
|
abstract val geodeVersion: Property<String>
|
||||||
|
|
||||||
|
@get:OutputFile
|
||||||
|
abstract val targetFile: RegularFileProperty
|
||||||
|
|
||||||
|
@TaskAction
|
||||||
|
fun execute() {
|
||||||
|
val downloadGeodeVersion = geodeVersion.get()
|
||||||
|
val destinationFile = targetFile.get().asFile
|
||||||
|
|
||||||
|
val releaseUrl = "https://api.geode-sdk.org/v1/loader/versions/$downloadGeodeVersion"
|
||||||
|
URI.create(releaseUrl).toURL().openStream().use { response ->
|
||||||
|
destinationFile.outputStream().use { destination ->
|
||||||
|
response.copyTo(destination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class DownloadGeodeFileTask : DefaultTask() {
|
||||||
|
@get:Input
|
||||||
|
abstract val platform: Property<String>
|
||||||
|
|
||||||
|
@get:InputFile
|
||||||
|
abstract val metadata: RegularFileProperty
|
||||||
|
|
||||||
|
private fun downloadAndVerifyFile(filename: String, url: String, hash: String?): File {
|
||||||
|
logger.info("Downloading $filename from $url")
|
||||||
|
|
||||||
|
val tempFile = File(temporaryDir, filename)
|
||||||
|
|
||||||
|
URI.create(url).toURL().openStream().use { response ->
|
||||||
|
tempFile.outputStream().use { output ->
|
||||||
|
response.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hash != null) {
|
||||||
|
val downloadedHash = MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(tempFile.readBytes())
|
||||||
|
.toHexString()
|
||||||
|
|
||||||
|
if (downloadedHash != hash) {
|
||||||
|
throw GradleException("Hash check failed for file $filename, expected $hash but found $downloadedHash.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tempFile
|
||||||
|
}
|
||||||
|
|
||||||
|
fun downloadZip(fallbackUrl: String?): File {
|
||||||
|
val downloadPlatform = platform.get()
|
||||||
|
|
||||||
|
val metadataFile = metadata.get().asFile
|
||||||
|
val json = JsonSlurper().parse(metadataFile) as Map<*, *>
|
||||||
|
|
||||||
|
val payload = json["payload"] as? Map<*, *>
|
||||||
|
|
||||||
|
val downloads = payload?.get("downloads") as? Map<*, *>
|
||||||
|
val platformInfo = downloads?.get(downloadPlatform) as? Map<*, *>
|
||||||
|
|
||||||
|
val downloadUrl = platformInfo?.get("url") as? String ?: fallbackUrl
|
||||||
|
if (downloadUrl == null) {
|
||||||
|
throw GradleException("Failed to fetch download url for platform $platform.")
|
||||||
|
}
|
||||||
|
|
||||||
|
val expectedHash = platformInfo?.get("hash") as? String
|
||||||
|
|
||||||
|
return downloadAndVerifyFile("$downloadPlatform.zip", downloadUrl, expectedHash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class DownloadAndUnzipGeodeTask : DownloadGeodeFileTask() {
|
||||||
|
@get:Input
|
||||||
|
abstract val geodeVersion: Property<String>
|
||||||
|
|
||||||
|
@get:OutputFile
|
||||||
|
abstract val targetFile: RegularFileProperty
|
||||||
|
|
||||||
|
@TaskAction
|
||||||
|
fun execute() {
|
||||||
|
val downloadGeodeVersion = geodeVersion.get()
|
||||||
|
val downloadPlatform = platform.get()
|
||||||
|
val destinationFile = targetFile.get().asFile
|
||||||
|
|
||||||
|
val tempOutput = this.downloadZip("https://github.com/geode-sdk/geode/releases/download/v$downloadGeodeVersion/geode-v$downloadGeodeVersion-$downloadPlatform.zip")
|
||||||
|
|
||||||
|
val expectedFile = "Geode.$downloadPlatform.so"
|
||||||
|
|
||||||
|
ZipInputStream(tempOutput.inputStream()).use { zip ->
|
||||||
|
var entry = zip.nextEntry
|
||||||
|
while (entry != null) {
|
||||||
|
if (entry.name == expectedFile) {
|
||||||
|
destinationFile.outputStream().use {
|
||||||
|
zip.copyTo(it)
|
||||||
|
}
|
||||||
|
return@execute
|
||||||
|
}
|
||||||
|
|
||||||
|
entry = zip.nextEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
throw GradleException("Could not find Geode release.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class DownloadAndUnzipGeodeResourcesTask : DownloadGeodeFileTask() {
|
||||||
|
init {
|
||||||
|
platform.set("resources")
|
||||||
|
}
|
||||||
|
|
||||||
|
@get:Input
|
||||||
|
abstract val geodeVersion: Property<String>
|
||||||
|
|
||||||
|
@get:OutputDirectory
|
||||||
|
abstract val outputDir: DirectoryProperty
|
||||||
|
|
||||||
|
@TaskAction
|
||||||
|
fun execute() {
|
||||||
|
val downloadGeodeVersion = geodeVersion.get()
|
||||||
|
|
||||||
|
val tempOutput = this.downloadZip("https://github.com/geode-sdk/geode/releases/download/v$downloadGeodeVersion/resources.zip")
|
||||||
|
|
||||||
|
val baseOutput = outputDir.get().asFile
|
||||||
|
if (baseOutput.exists()) baseOutput.deleteRecursively()
|
||||||
|
|
||||||
|
val output = File(baseOutput, "geode.loader")
|
||||||
|
output.mkdirs()
|
||||||
|
|
||||||
|
val canonicalOutput = output.canonicalPath
|
||||||
|
|
||||||
|
ZipInputStream(tempOutput.inputStream()).use { zipStream ->
|
||||||
|
var entry = zipStream.nextEntry
|
||||||
|
while (entry != null) {
|
||||||
|
val destination = File(output, entry.name)
|
||||||
|
if (!destination.canonicalPath.startsWith(canonicalOutput)) {
|
||||||
|
throw GradleException("attempted copy to outside of output directory: ${entry.name}")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (destination.isDirectory) {
|
||||||
|
destination.mkdirs()
|
||||||
|
} else {
|
||||||
|
destination.parentFile?.mkdirs()
|
||||||
|
|
||||||
|
destination.outputStream().use { destinationStream ->
|
||||||
|
zipStream.copyTo(destinationStream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry = zipStream.nextEntry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val fetchGeodeMetadataTask = tasks.register<DownloadVersionMetadataTask>("fetchGeodeMetadata") {
|
||||||
|
description = "Fetches and saves a Geode version's metadata"
|
||||||
|
inputs.property("geodeVersion", currentGeodeVersion)
|
||||||
|
|
||||||
|
geodeVersion = currentGeodeVersion
|
||||||
|
targetFile = temporaryDir.resolve("metadata.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
val downloadGeode32Task = tasks.register<DownloadAndUnzipGeodeTask>("downloadGeode32Binary") {
|
||||||
|
description = "Downloads the 32bit Geode release archive"
|
||||||
|
inputs.property("geodeVersion", currentGeodeVersion)
|
||||||
|
|
||||||
|
geodeVersion = currentGeodeVersion
|
||||||
|
platform = "android32"
|
||||||
|
metadata = fetchGeodeMetadataTask.flatMap { it.targetFile }
|
||||||
|
targetFile = temporaryDir.resolve("libgeode.so")
|
||||||
|
}
|
||||||
|
|
||||||
|
val downloadGeode64Task = tasks.register<DownloadAndUnzipGeodeTask>("downloadGeode64Binary") {
|
||||||
|
description = "Downloads the 64bit Geode release archive"
|
||||||
|
inputs.property("geodeVersion", currentGeodeVersion)
|
||||||
|
|
||||||
|
geodeVersion = currentGeodeVersion
|
||||||
|
platform = "android64"
|
||||||
|
metadata = fetchGeodeMetadataTask.flatMap { it.targetFile }
|
||||||
|
targetFile = temporaryDir.resolve("libgeode.so")
|
||||||
|
}
|
||||||
|
|
||||||
|
val downloadGeodeResourcesTask = tasks.register<DownloadAndUnzipGeodeResourcesTask>("downloadGeodeResources") {
|
||||||
|
description = "Downloads Geode resources"
|
||||||
|
inputs.property("geodeVersion", currentGeodeVersion)
|
||||||
|
|
||||||
|
geodeVersion = currentGeodeVersion
|
||||||
|
metadata = fetchGeodeMetadataTask.flatMap { it.targetFile }
|
||||||
|
outputDir = layout.buildDirectory.dir("geode-resources")
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class MergeGeodeFilesTask : DefaultTask() {
|
||||||
|
@get:InputFile
|
||||||
|
abstract val binary32: RegularFileProperty
|
||||||
|
|
||||||
|
@get:InputFile
|
||||||
|
abstract val binary64: RegularFileProperty
|
||||||
|
|
||||||
|
@get:OutputDirectory
|
||||||
|
abstract val outputDir: DirectoryProperty
|
||||||
|
|
||||||
|
@TaskAction
|
||||||
|
fun execute() {
|
||||||
|
val out = outputDir.get().asFile
|
||||||
|
out.deleteRecursively()
|
||||||
|
out.mkdirs()
|
||||||
|
|
||||||
|
binary32.get().asFile.copyTo(outputDir.file("armeabi-v7a/libgeode.so").get().asFile)
|
||||||
|
binary64.get().asFile.copyTo(outputDir.file("arm64-v8a/libgeode.so").get().asFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val fullDownloadTask = tasks.register<MergeGeodeFilesTask>("downloadGeodeFull") {
|
||||||
|
description = "Downloads both Geode releases"
|
||||||
|
|
||||||
|
binary32 = downloadGeode32Task.flatMap { it.targetFile }
|
||||||
|
binary64 = downloadGeode64Task.flatMap { it.targetFile }
|
||||||
|
outputDir = layout.buildDirectory.dir("geode-prebuilt")
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
compileSdk = 34
|
compileSdk = 37
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.geode.launcher"
|
applicationId = "com.geode.launcher"
|
||||||
minSdk = 23
|
minSdk = 23
|
||||||
targetSdk = 35
|
targetSdk = 37
|
||||||
versionCode = 17
|
versionCode = 31
|
||||||
versionName = "1.4.0"
|
versionName = "1.9.0"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
@@ -24,7 +258,7 @@ android {
|
|||||||
@Suppress("UnstableApiUsage")
|
@Suppress("UnstableApiUsage")
|
||||||
externalNativeBuild {
|
externalNativeBuild {
|
||||||
cmake {
|
cmake {
|
||||||
arguments("-DUSE_TULIPHOOK:BOOL=OFF", "-DANDROID_STL=c++_shared")
|
arguments("-DANDROID_STL=c++_shared")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +268,7 @@ android {
|
|||||||
|
|
||||||
splits {
|
splits {
|
||||||
abi {
|
abi {
|
||||||
isEnable = true
|
isEnable = !project.hasProperty("disableAbiSplits")
|
||||||
reset()
|
reset()
|
||||||
|
|
||||||
//noinspection ChromeOsAbiSupport. i'm sorry!
|
//noinspection ChromeOsAbiSupport. i'm sorry!
|
||||||
@@ -44,6 +278,24 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
flavorDimensions += "distribution"
|
||||||
|
|
||||||
|
productFlavors {
|
||||||
|
create("standard") {
|
||||||
|
dimension = "distribution"
|
||||||
|
isDefault = true
|
||||||
|
buildConfigField("boolean", "GOOGLE_PLAY_BUILD", "false")
|
||||||
|
buildConfigField("String", "PREBUNDLED_GEODE", "null")
|
||||||
|
}
|
||||||
|
|
||||||
|
create("googlePlay") {
|
||||||
|
dimension = "distribution"
|
||||||
|
applicationId = "com.geode.launcher.play"
|
||||||
|
buildConfigField("boolean", "GOOGLE_PLAY_BUILD", "true")
|
||||||
|
buildConfigField("String", "PREBUNDLED_GEODE", "\"$currentGeodeVersion\"")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
@@ -59,11 +311,8 @@ android {
|
|||||||
// enables a polyfill for java Instant on api levels < 26 (used for updater)
|
// enables a polyfill for java Instant on api levels < 26 (used for updater)
|
||||||
isCoreLibraryDesugaringEnabled = true
|
isCoreLibraryDesugaringEnabled = true
|
||||||
|
|
||||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
targetCompatibility = JavaVersion.VERSION_1_8
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
}
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "1.8"
|
|
||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
@@ -76,30 +325,51 @@ android {
|
|||||||
}
|
}
|
||||||
externalNativeBuild {
|
externalNativeBuild {
|
||||||
cmake {
|
cmake {
|
||||||
|
version = "3.21.0+"
|
||||||
path = file("src/main/cpp/CMakeLists.txt")
|
path = file("src/main/cpp/CMakeLists.txt")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
namespace = "com.geode.launcher"
|
namespace = "com.geode.launcher"
|
||||||
ndkVersion = "26.3.11579264"
|
ndkVersion = "30.0.16248370"
|
||||||
|
}
|
||||||
|
|
||||||
|
androidComponents {
|
||||||
|
onVariants(selector().withFlavor("distribution" to "googlePlay")) { variant ->
|
||||||
|
variant.sources.jniLibs?.addGeneratedSourceDirectory(
|
||||||
|
fullDownloadTask,
|
||||||
|
MergeGeodeFilesTask::outputDir
|
||||||
|
)
|
||||||
|
variant.sources.assets?.addGeneratedSourceDirectory(
|
||||||
|
downloadGeodeResourcesTask,
|
||||||
|
DownloadAndUnzipGeodeResourcesTask::outputDir
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation (platform("androidx.compose:compose-bom:$composeBOM"))
|
implementation (platform("androidx.compose:compose-bom:2026.09.00"))
|
||||||
implementation ("androidx.core:core-ktx:1.13.1")
|
implementation ("androidx.core:core-ktx:1.19.1")
|
||||||
implementation ("androidx.compose.ui:ui")
|
implementation ("androidx.compose.ui:ui")
|
||||||
implementation ("androidx.compose.material3:material3")
|
implementation ("androidx.compose.material3:material3")
|
||||||
implementation ("androidx.compose.ui:ui-tooling-preview")
|
implementation ("androidx.compose.ui:ui-tooling-preview")
|
||||||
implementation ("androidx.lifecycle:lifecycle-runtime-ktx:2.8.2")
|
implementation ("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0")
|
||||||
implementation ("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.2")
|
implementation ("androidx.lifecycle:lifecycle-viewmodel-compose:2.11.0")
|
||||||
implementation ("androidx.activity:activity-compose:1.9.0")
|
implementation ("androidx.activity:activity-compose:1.13.0")
|
||||||
implementation ("androidx.activity:activity-ktx:1.9.0")
|
implementation ("androidx.activity:activity-ktx:1.13.0")
|
||||||
implementation ("androidx.appcompat:appcompat:1.7.0")
|
implementation ("androidx.appcompat:appcompat:1.8.0")
|
||||||
implementation ("androidx.documentfile:documentfile:1.0.1")
|
implementation ("androidx.documentfile:documentfile:1.1.0")
|
||||||
implementation ("com.squareup.okio:okio:3.9.0")
|
|
||||||
implementation ("com.squareup.okhttp3:okhttp:4.12.0")
|
implementation ("com.squareup.okio:okio:3.18.2")
|
||||||
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.0")
|
implementation ("com.squareup.okhttp3:okhttp:5.5.0")
|
||||||
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json-okio:1.7.0")
|
implementation ("com.squareup.okhttp3:okhttp-coroutines:5.5.0")
|
||||||
implementation ("org.jetbrains.kotlinx:kotlinx-datetime:0.6.0")
|
|
||||||
|
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
|
||||||
|
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json-okio:1.11.0")
|
||||||
|
implementation ("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
|
||||||
|
|
||||||
|
implementation ("com.mikepenz:multiplatform-markdown-renderer-android:0.46.0")
|
||||||
|
implementation ("com.mikepenz:multiplatform-markdown-renderer-m3:0.46.0")
|
||||||
|
implementation ("androidx.browser:browser:1.10.0")
|
||||||
debugImplementation ("androidx.compose.ui:ui-tooling")
|
debugImplementation ("androidx.compose.ui:ui-tooling")
|
||||||
coreLibraryDesugaring ("com.android.tools:desugar_jdk_libs:2.0.4")
|
coreLibraryDesugaring ("com.android.tools:desugar_jdk_libs:2.1.5")
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.REQUEST_INSTALL_PACKAGES"
|
||||||
|
tools:node="remove" />
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -17,10 +17,10 @@
|
|||||||
android:maxSdkVersion="32" />
|
android:maxSdkVersion="32" />
|
||||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
<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_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.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<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"
|
||||||
@@ -28,47 +28,108 @@
|
|||||||
<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: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:requestLegacyExternalStorage="true"
|
||||||
android:theme="@style/Theme.GeodeLauncher"
|
android:theme="@style/Theme.GeodeLauncher"
|
||||||
tools:targetApi="31">
|
tools:targetApi="35">
|
||||||
<activity
|
<receiver
|
||||||
android:name=".AltMainActivity"
|
android:name=".InstallReceiver"
|
||||||
android:exported="false"
|
android:enabled="true"
|
||||||
android:theme="@style/Theme.GeodeLauncher" />
|
android:exported="false" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".preferences.DeveloperSettingsActivity"
|
android:name=".preferences.DeveloperSettingsActivity"
|
||||||
android:exported="false"
|
android:exported="true"
|
||||||
android:label="@string/title_activity_developer_settings"
|
android:label="@string/title_activity_developer_settings"
|
||||||
android:theme="@style/Theme.GeodeLauncher" />
|
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
|
<activity
|
||||||
android:name=".preferences.ApplicationLogsActivity"
|
android:name=".preferences.ApplicationLogsActivity"
|
||||||
android:exported="false"
|
android:exported="true"
|
||||||
android:label="@string/title_activity_application_logs"
|
android:label="@string/title_activity_application_logs"
|
||||||
|
android:theme="@style/Theme.GeodeLauncher">
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="geode-launcher" android:host="logs" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<activity
|
||||||
|
android:name=".preferences.CrashLogsActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/title_activity_crash_logs"
|
||||||
|
android:theme="@style/Theme.GeodeLauncher">
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="geode-launcher" android:host="crashes" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<activity
|
||||||
|
android:name=".preferences.GeodeLogsListingActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/title_activity_geode_logs"
|
||||||
|
android:theme="@style/Theme.GeodeLauncher">
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="geode-launcher" android:host="geode-logs" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<activity
|
||||||
|
android:name=".preferences.TextViewActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android: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" />
|
android:theme="@style/Theme.GeodeLauncher" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".preferences.SettingsActivity"
|
android:name=".preferences.SettingsActivity"
|
||||||
android:exported="false"
|
android:exported="true"
|
||||||
android:label="@string/title_activity_settings"
|
android:label="@string/title_activity_settings"
|
||||||
android:theme="@style/Theme.GeodeLauncher">
|
android:theme="@style/Theme.GeodeLauncher">
|
||||||
<meta-data
|
|
||||||
android:name="android.app.lib_name"
|
<intent-filter>
|
||||||
android:value="" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="geode-launcher" android:host="settings" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<activity
|
<activity
|
||||||
android:name=".GeometryDashActivity"
|
android:name=".GeometryDashActivity"
|
||||||
|
android:configChanges="fontScale|layoutDirection|density|smallestScreenSize|screenSize|uiMode|screenLayout|orientation|navigation|keyboardHidden|keyboard|locale"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
android:screenOrientation="landscape"
|
android:screenOrientation="landscape"
|
||||||
android:theme="@style/Theme.NoActionBar.Fullscreen"
|
android:theme="@style/Theme.NoActionBar.Fullscreen"
|
||||||
android:configChanges="orientation|screenSize|smallestScreenSize"
|
|
||||||
android:windowSoftInputMode="stateAlwaysHidden">
|
android:windowSoftInputMode="stateAlwaysHidden">
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.app.lib_name"
|
android:name="android.app.lib_name"
|
||||||
@@ -83,11 +144,119 @@
|
|||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
|
<activity-alias
|
||||||
|
android:name=".MainActivityTrans"
|
||||||
|
android:enabled="false"
|
||||||
|
android:exported="true"
|
||||||
|
android:icon="@mipmap/ic_launcher_trans"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:targetActivity=".MainActivity">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="geode-launcher" android:host="main" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity-alias>
|
||||||
|
<activity-alias
|
||||||
|
android:name=".MainActivityPride"
|
||||||
|
android:enabled="false"
|
||||||
|
android:exported="true"
|
||||||
|
android:icon="@mipmap/ic_launcher_pride"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:targetActivity=".MainActivity">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="geode-launcher" android:host="main" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity-alias>
|
||||||
|
<activity-alias
|
||||||
|
android:name=".MainActivitySapphire"
|
||||||
|
android:enabled="false"
|
||||||
|
android:exported="true"
|
||||||
|
android:icon="@mipmap/ic_launcher_sapphire"
|
||||||
|
android:label="@string/application_icon_sapphire"
|
||||||
|
android:targetActivity=".MainActivity">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="geode-launcher" android:host="main" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity-alias>
|
||||||
|
<activity-alias
|
||||||
|
android:name=".MainActivityGeode"
|
||||||
|
android:enabled="false"
|
||||||
|
android:exported="true"
|
||||||
|
android:icon="@mipmap/ic_launcher_geode"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:targetActivity=".MainActivity">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="geode-launcher" android:host="main" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity-alias>
|
||||||
|
<activity-alias
|
||||||
|
android:name=".MainActivityGreen"
|
||||||
|
android:enabled="false"
|
||||||
|
android:exported="true"
|
||||||
|
android:icon="@mipmap/ic_launcher_green"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:targetActivity=".MainActivity">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="geode-launcher" android:host="main" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity-alias>
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name=".UserDirectoryProvider"
|
android:name=".UserDirectoryProvider"
|
||||||
android:authorities="com.geode.launcher.user"
|
android:authorities="${applicationId}.user"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:grantUriPermissions="true"
|
android:grantUriPermissions="true"
|
||||||
android:permission="android.permission.MANAGE_DOCUMENTS">
|
android:permission="android.permission.MANAGE_DOCUMENTS">
|
||||||
@@ -97,7 +266,7 @@
|
|||||||
</provider>
|
</provider>
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="com.geode.launcher.fileprovider"
|
android:authorities="${applicationId}.fileprovider"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:grantUriPermissions="true">
|
android:grantUriPermissions="true">
|
||||||
<meta-data
|
<meta-data
|
||||||
|
|||||||
@@ -1,46 +1,17 @@
|
|||||||
cmake_minimum_required(VERSION 3.18.1)
|
cmake_minimum_required(VERSION 3.21)
|
||||||
|
|
||||||
project(launcherfix)
|
project(launcherfix)
|
||||||
|
|
||||||
add_library(launcherfix SHARED launcher-fix.cpp)
|
add_library(launcherfix SHARED launcher-fix.cpp patcher.cpp)
|
||||||
|
|
||||||
target_compile_features(launcherfix PUBLIC cxx_std_20)
|
target_compile_features(launcherfix PUBLIC cxx_std_20)
|
||||||
set_target_properties(launcherfix PROPERTIES CXX_EXTENSIONS OFF)
|
set_target_properties(launcherfix PROPERTIES CXX_EXTENSIONS OFF)
|
||||||
|
|
||||||
include(FetchContent)
|
set_target_properties(launcherfix PROPERTIES
|
||||||
|
INTERPROCEDURAL_OPTIMIZATION ON
|
||||||
option(USE_TULIPHOOK "enables choice of hooking library" OFF)
|
UNITY_BUILD ON
|
||||||
if (USE_TULIPHOOK)
|
C_VISIBILITY_PRESET hidden
|
||||||
FetchContent_Declare(TulipHook
|
CXX_VISIBILITY_PRESET hidden
|
||||||
GIT_REPOSITORY https://github.com/geode-sdk/TulipHook.git
|
|
||||||
GIT_TAG 0021301fcea265416fa8eb5372694931c1d410cb
|
|
||||||
)
|
)
|
||||||
|
|
||||||
set(TULIP_LINK_SOURCE ON CACHE INTERNAL "")
|
|
||||||
FetchContent_MakeAvailable(TulipHook)
|
|
||||||
|
|
||||||
target_compile_definitions(launcherfix PRIVATE -DUSE_TULIPHOOK=1)
|
|
||||||
|
|
||||||
target_link_libraries(launcherfix PRIVATE TulipHook TulipHookInclude)
|
|
||||||
else()
|
|
||||||
FetchContent_Declare(dobby
|
|
||||||
GIT_REPOSITORY https://github.com/jmpews/Dobby
|
|
||||||
GIT_TAG 6a80a61ea062424d1595613058a174c64454fa57
|
|
||||||
)
|
|
||||||
|
|
||||||
FetchContent_GetProperties(dobby)
|
|
||||||
if(NOT dobby_POPULATED)
|
|
||||||
FetchContent_Populate(dobby)
|
|
||||||
|
|
||||||
# fixes Dobby build
|
|
||||||
# add_compile_definitions(typeof=__typeof__)
|
|
||||||
set(DOBBY_DEBUG OFF CACHE INTERNAL "" FORCE)
|
|
||||||
set(DOBBY_GENERATE_SHARED OFF CACHE INTERNAL "" FORCE)
|
|
||||||
|
|
||||||
add_subdirectory("${dobby_SOURCE_DIR}" ${dobby_BINARY_DIR} EXCLUDE_FROM_ALL)
|
|
||||||
target_include_directories(launcherfix PRIVATE ${dobby_SOURCE_DIR}/include)
|
|
||||||
target_link_libraries(launcherfix PRIVATE dobby)
|
|
||||||
endif()
|
|
||||||
endif()
|
|
||||||
|
|
||||||
target_link_libraries(launcherfix PRIVATE log)
|
target_link_libraries(launcherfix PRIVATE log)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <array>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
#include <link.h>
|
||||||
|
|
||||||
|
class DataPaths {
|
||||||
|
public:
|
||||||
|
static std::string original_data_path;
|
||||||
|
static std::string data_path;
|
||||||
|
|
||||||
|
static bool patch_exceptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
// this is every function that i thought would be relevant
|
||||||
|
constexpr std::array<const std::string_view, 10> remove_symbols{
|
||||||
|
"__gxx_personality_v0",
|
||||||
|
"__cxa_throw",
|
||||||
|
"__cxa_rethrow",
|
||||||
|
"__cxa_allocate_exception",
|
||||||
|
"__cxa_end_catch",
|
||||||
|
"__cxa_begin_catch",
|
||||||
|
"__cxa_guard_abort",
|
||||||
|
"__cxa_guard_acquire",
|
||||||
|
"__cxa_guard_release",
|
||||||
|
"__cxa_free_exception"
|
||||||
|
// conflicts with fix within geode on some devices
|
||||||
|
// "__emutls_get_address", // added in 2.208
|
||||||
|
// "__cxa_thread_atexit"
|
||||||
|
|
||||||
|
// android 10's libc doesn't have these symbols
|
||||||
|
// and the ndk is forbidden from exporting them!! so keep it for old mods...
|
||||||
|
// "_Unwind_RaiseException",
|
||||||
|
// "_Unwind_Resume"
|
||||||
|
};
|
||||||
|
|
||||||
|
FILE* fopen_hook(const char* pathname, const char* mode);
|
||||||
|
int rename_hook(const char* old_path, const char* new_path);
|
||||||
|
|
||||||
|
int on_dl_iterate(dl_phdr_info* info, size_t size, void* data);
|
||||||
@@ -1,35 +1,13 @@
|
|||||||
#include <dlfcn.h>
|
|
||||||
#include <jni.h>
|
#include <jni.h>
|
||||||
#include <fstream>
|
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <cstdint>
|
|
||||||
#include <android/log.h>
|
|
||||||
#include <link.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{};
|
|
||||||
std::string load_symbols_from{};
|
|
||||||
|
|
||||||
static DataPaths& get_instance() {
|
|
||||||
static auto paths_instance = DataPaths();
|
|
||||||
return paths_instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
private:
|
|
||||||
DataPaths() {}
|
|
||||||
};
|
|
||||||
|
|
||||||
extern "C"
|
extern "C"
|
||||||
JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setDataPath(
|
JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setDataPath(
|
||||||
@@ -40,35 +18,11 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
extern "C"
|
|
||||||
JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_enableCustomSymbolList(
|
|
||||||
JNIEnv* env,
|
|
||||||
jobject,
|
|
||||||
jstring symbol_path
|
|
||||||
) {
|
|
||||||
auto is_copy = jboolean();
|
|
||||||
auto symbol_path_str = env->GetStringUTFChars(symbol_path, &is_copy);
|
|
||||||
|
|
||||||
DataPaths::get_instance().load_symbols_from = std::string(symbol_path_str);
|
|
||||||
|
|
||||||
env->ReleaseStringUTFChars(symbol_path, symbol_path_str);
|
|
||||||
}
|
|
||||||
|
|
||||||
#ifdef __arm__
|
|
||||||
// 32bit code
|
|
||||||
typedef Elf32_Dyn Elf_Dyn;
|
|
||||||
typedef Elf32_Sym Elf_Sym;
|
|
||||||
#else
|
|
||||||
// otherwise we're probably 64bit
|
|
||||||
typedef Elf64_Dyn Elf_Dyn;
|
|
||||||
typedef Elf64_Sym Elf_Sym;
|
|
||||||
#endif
|
|
||||||
|
|
||||||
extern "C"
|
extern "C"
|
||||||
JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setOriginalDataPath(
|
JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setOriginalDataPath(
|
||||||
JNIEnv *env,
|
JNIEnv *env,
|
||||||
@@ -78,262 +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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// i copied this function from the android linker
|
extern "C"
|
||||||
std::uint32_t elfhash(const char *_name) {
|
JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_enableExceptionsRenaming(JNIEnv*, jobject) {
|
||||||
const unsigned char *name = reinterpret_cast<const unsigned char*>(_name);
|
DataPaths::patch_exceptions = true;
|
||||||
std::uint32_t h = 0, g;
|
|
||||||
|
|
||||||
while (*name) {
|
|
||||||
h = (h << 4) + *name++;
|
|
||||||
g = h & 0xf0000000;
|
|
||||||
h ^= g;
|
|
||||||
h ^= g >> 24;
|
|
||||||
}
|
|
||||||
|
|
||||||
return h;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool patch_symbol(std::uint32_t* hash_table, char* str_table, Elf_Sym* sym_table, const char* 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 (strcmp(sym_name, orig_name) == 0) {
|
|
||||||
// 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
|
|
||||||
std::array<std::uint8_t, 3> new_symbol{":3"};
|
|
||||||
|
|
||||||
#ifdef USE_TULIPHOOK
|
|
||||||
auto res = tulip::hook::writeMemory(sym_name, new_symbol.data(), new_symbol.size());
|
|
||||||
if (!res) {
|
|
||||||
__android_log_print(ANDROID_LOG_WARN, "GeodeLauncher-fix", "failed to patch symbol: %s", res.unwrapErr().c_str());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
DobbyCodePatch(reinterpret_cast<void*>(sym_name), new_symbol.data(), new_symbol.size());
|
|
||||||
#endif
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
__android_log_print(ANDROID_LOG_WARN, "GeodeLauncher-fix", "could not find symbol %s to patch", orig_name);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::vector<std::string> get_symbols_listing() {
|
|
||||||
auto symbol_path = DataPaths::get_instance().load_symbols_from;
|
|
||||||
if (symbol_path.empty()) {
|
|
||||||
// this is every function that i thought would be relevant
|
|
||||||
return {
|
|
||||||
"__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",
|
|
||||||
"_Unwind_RaiseException",
|
|
||||||
"_Unwind_Resume"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
std::ifstream symbol_file{symbol_path};
|
|
||||||
if (!symbol_file) {
|
|
||||||
__android_log_print(ANDROID_LOG_WARN, "GeodeLauncher-fix", "failed to read symbol file at %s", symbol_path.c_str());
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
std::vector<std::string> symbols_list{};
|
|
||||||
std::string current_line{};
|
|
||||||
while (std::getline(symbol_file, current_line)) {
|
|
||||||
if (!current_line.empty()) {
|
|
||||||
symbols_list.push_back(current_line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return symbols_list;
|
|
||||||
}
|
|
||||||
|
|
||||||
int on_dl_iterate(dl_phdr_info* info, size_t size, void* data) {
|
|
||||||
// this is probably going to be gd
|
|
||||||
if (strstr(info->dlpi_name, "libcocos2dcpp.so") != nullptr) {
|
|
||||||
// step 1: get the dynamic table
|
|
||||||
std::uintptr_t dyn_addr = 0u;
|
|
||||||
|
|
||||||
for (auto i = 0u; i < info->dlpi_phnum; i++) {
|
|
||||||
auto phdr = info->dlpi_phdr + i;
|
|
||||||
|
|
||||||
if (phdr->p_type == PT_DYNAMIC) {
|
|
||||||
dyn_addr = info->dlpi_addr + phdr->p_vaddr;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dyn_addr == 0u) {
|
|
||||||
__android_log_print(ANDROID_LOG_WARN, "GeodeLauncher-fix", "failed to find libcocos dynamic section");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// step 2: get the symbol table
|
|
||||||
auto dyn_entry = reinterpret_cast<Elf_Dyn*>(dyn_addr);
|
|
||||||
auto dyn_end_reached = false;
|
|
||||||
|
|
||||||
std::uintptr_t sym_table_addr = 0u;
|
|
||||||
std::uintptr_t str_table_addr = 0u;
|
|
||||||
std::uintptr_t hash_table_addr = 0u;
|
|
||||||
|
|
||||||
while (!dyn_end_reached) {
|
|
||||||
auto tag = dyn_entry->d_tag;
|
|
||||||
|
|
||||||
switch (dyn_entry->d_tag) {
|
|
||||||
case DT_SYMTAB:
|
|
||||||
sym_table_addr = info->dlpi_addr + dyn_entry->d_un.d_val;
|
|
||||||
break;
|
|
||||||
case DT_STRTAB:
|
|
||||||
str_table_addr = info->dlpi_addr + dyn_entry->d_un.d_val;
|
|
||||||
break;
|
|
||||||
case DT_HASH:
|
|
||||||
hash_table_addr = info->dlpi_addr + dyn_entry->d_un.d_val;
|
|
||||||
break;
|
|
||||||
case DT_NULL:
|
|
||||||
dyn_end_reached = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
dyn_entry++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hash_table_addr == 0u || str_table_addr == 0u || sym_table_addr == 0u) {
|
|
||||||
__android_log_print(ANDROID_LOG_WARN, "GeodeLauncher-fix", "failed to parse dynamic section (at least one table is null)");
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// patch symbol names
|
|
||||||
auto hash_table = reinterpret_cast<std::uint32_t*>(hash_table_addr);
|
|
||||||
auto str_table = reinterpret_cast<char*>(str_table_addr);
|
|
||||||
auto sym_table = reinterpret_cast<Elf_Sym*>(sym_table_addr);
|
|
||||||
|
|
||||||
auto symbols_listing = get_symbols_listing();
|
|
||||||
for (const auto& symbol : symbols_listing) {
|
|
||||||
patch_symbol(hash_table, str_table, sym_table, symbol.c_str());
|
|
||||||
}
|
|
||||||
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// this should be called after gd is loaded but before geode
|
// this should be called after gd is loaded but before geode
|
||||||
extern "C"
|
extern "C" JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_performPatches(JNIEnv*, jobject) {
|
||||||
JNIEXPORT int JNICALL Java_com_geode_launcher_LauncherFix_performExceptionsRenaming(JNIEnv*, jobject) {
|
dl_iterate_phdr(on_dl_iterate, nullptr);
|
||||||
return dl_iterate_phdr(on_dl_iterate, nullptr);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string redirect_path(const char* pathname) {
|
std::optional<std::string> redirect_path(const char* pathname) {
|
||||||
auto data_path = DataPaths::get_instance().data_path;
|
std::string_view data_path = DataPaths::data_path;
|
||||||
auto original_data_path = DataPaths::get_instance().original_data_path;
|
std::string_view original_data_path = DataPaths::original_data_path;
|
||||||
|
|
||||||
|
if (data_path.empty() || original_data_path.empty()) [[unlikely]] {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
if (!data_path.empty() && !original_data_path.empty()) {
|
|
||||||
// call this a c string optimization
|
// call this a c string optimization
|
||||||
if (std::strncmp(pathname, original_data_path.c_str(), original_data_path.size()) == 0) {
|
if (std::strncmp(pathname, original_data_path.data(), original_data_path.size()) == 0) {
|
||||||
auto path = data_path + (pathname + original_data_path.size());
|
auto remaining_path = (pathname + original_data_path.size());
|
||||||
|
auto remaining_len = strlen(remaining_path);
|
||||||
|
|
||||||
return path;
|
std::string x;
|
||||||
}
|
x.reserve(data_path.size() + remaining_len);
|
||||||
|
x.append(data_path);
|
||||||
|
x.append(remaining_path, remaining_len);
|
||||||
|
|
||||||
|
return x;
|
||||||
}
|
}
|
||||||
|
|
||||||
return pathname;
|
return std::nullopt;
|
||||||
}
|
}
|
||||||
|
|
||||||
FILE* (*fopen_original)(const char *pathname, const char *mode);
|
|
||||||
FILE* fopen_hook(const char* pathname, const char* mode) {
|
FILE* fopen_hook(const char* pathname, const char* mode) {
|
||||||
auto path_str = redirect_path(pathname);
|
auto path_str = redirect_path(pathname);
|
||||||
|
auto redirect = path_str.has_value() ? path_str->c_str() : pathname;
|
||||||
|
|
||||||
return fopen_original(path_str.c_str(), mode);
|
return fopen(redirect, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
int (*rename_original)(const char* old_path, const char* new_path);
|
|
||||||
int rename_hook(const char* old_path, const char* new_path) {
|
int rename_hook(const char* old_path, const char* new_path) {
|
||||||
auto old_path_str = redirect_path(old_path);
|
auto old_path_str = redirect_path(old_path);
|
||||||
|
auto old_redirect = old_path_str ? old_path_str->c_str() : old_path;
|
||||||
|
|
||||||
auto new_path_str = redirect_path(new_path);
|
auto new_path_str = redirect_path(new_path);
|
||||||
|
auto new_redirect = new_path_str ? new_path_str->c_str() : new_path;
|
||||||
|
|
||||||
return rename_original(old_path_str.c_str(), new_path_str.c_str());
|
return rename(old_redirect, new_redirect);
|
||||||
}
|
|
||||||
|
|
||||||
bool hook_function(void* addr, auto* hook, auto** orig) {
|
|
||||||
#ifdef USE_TULIPHOOK
|
|
||||||
using namespace tulip::hook;
|
|
||||||
|
|
||||||
HandlerMetadata metadata = {
|
|
||||||
std::make_shared<PlatformConvention>(),
|
|
||||||
AbstractFunction::from(hook)
|
|
||||||
};
|
|
||||||
|
|
||||||
auto handler_result = createHandler(addr, metadata);
|
|
||||||
if (!handler_result) {
|
|
||||||
__android_log_print(ANDROID_LOG_WARN, "GeodeLauncher-Fix",
|
|
||||||
"failed to create handler: %s\n", handler_result.unwrapErr().c_str());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
auto handler = *handler_result;
|
|
||||||
|
|
||||||
HookMetadata hook_metadata = {0};
|
|
||||||
|
|
||||||
createHook(handler, reinterpret_cast<void*>(hook), hook_metadata);
|
|
||||||
|
|
||||||
WrapperMetadata wrapper_metadata = {
|
|
||||||
std::make_unique<PlatformConvention>(),
|
|
||||||
AbstractFunction::from(hook)
|
|
||||||
};
|
|
||||||
|
|
||||||
auto wrapper = createWrapper(addr, std::move(wrapper_metadata));
|
|
||||||
if (!wrapper) {
|
|
||||||
__android_log_print(ANDROID_LOG_WARN, "GeodeLauncher-Fix",
|
|
||||||
"failed to create wrapper: %s\n", wrapper.unwrapErr().c_str());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
*reinterpret_cast<void**>(orig) = *wrapper;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
#else
|
|
||||||
DobbyHook(
|
|
||||||
addr,
|
|
||||||
reinterpret_cast<dobby_dummy_func_t>(hook),
|
|
||||||
reinterpret_cast<dobby_dummy_func_t*>(orig)
|
|
||||||
);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
[[gnu::constructor]] [[gnu::used]] void setup_hooks() {
|
|
||||||
#ifndef DISABLE_LAUNCHER_FIX
|
|
||||||
auto fopen_addr = dlsym(RTLD_NEXT, "fopen");
|
|
||||||
hook_function(fopen_addr, &fopen_hook, &fopen_original);
|
|
||||||
|
|
||||||
auto rename_addr = dlsym(RTLD_NEXT, "rename");
|
|
||||||
hook_function(rename_addr, &rename_hook, &rename_original);
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#define LOG_TAG "GeodeLauncher-Fix"
|
||||||
|
|
||||||
|
#include <android/log.h>
|
||||||
|
#include <format>
|
||||||
|
|
||||||
|
namespace log {
|
||||||
|
template<typename... Args>
|
||||||
|
void log_internal(android_LogPriority prio, std::format_string<Args...> fmt, Args&&... args) {
|
||||||
|
std::string str = std::format(fmt, std::forward<Args>(args)...);
|
||||||
|
__android_log_write(prio, LOG_TAG, str.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
template<typename... Args>
|
||||||
|
inline void debug(std::format_string<Args...> fmt, Args&&... args) {
|
||||||
|
log_internal(ANDROID_LOG_DEBUG, fmt, std::forward<Args>(args)...);
|
||||||
|
}
|
||||||
|
|
||||||
|
template<typename... Args>
|
||||||
|
inline void info(std::format_string<Args...> fmt, Args&&... args) {
|
||||||
|
log_internal(ANDROID_LOG_INFO, fmt, std::forward<Args>(args)...);
|
||||||
|
}
|
||||||
|
|
||||||
|
template<typename... Args>
|
||||||
|
inline void warn(std::format_string<Args...> fmt, Args&&... args) {
|
||||||
|
log_internal(ANDROID_LOG_WARN, fmt, std::forward<Args>(args)...);
|
||||||
|
}
|
||||||
|
|
||||||
|
template<typename... Args>
|
||||||
|
inline void error(std::format_string<Args...> fmt, Args&&... args) {
|
||||||
|
log_internal(ANDROID_LOG_ERROR, fmt, std::forward<Args>(args)...);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
#include <cstdint>
|
||||||
|
#include <span>
|
||||||
|
|
||||||
|
#include <link.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <sys/mman.h>
|
||||||
|
|
||||||
|
#include "log.hpp"
|
||||||
|
#include "base.h"
|
||||||
|
|
||||||
|
struct ElfDynamicState {
|
||||||
|
std::uint32_t* hash_table{};
|
||||||
|
char* str_table{};
|
||||||
|
ElfW(Sym)* sym_table{};
|
||||||
|
|
||||||
|
void* plt_table{};
|
||||||
|
std::uint64_t plt_table_size_bytes{};
|
||||||
|
bool plt_table_rela{};
|
||||||
|
|
||||||
|
std::uint64_t soname_idx{};
|
||||||
|
|
||||||
|
bool is_empty() const {
|
||||||
|
return !hash_table || !str_table || !sym_table || !plt_table;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// copied from tuliphook source :)
|
||||||
|
// tuliphook sets WRX perms, but we write to pages with W^X protection
|
||||||
|
bool set_mprotect(std::uintptr_t address, size_t size, int protection) {
|
||||||
|
auto const page_size = getpagesize();
|
||||||
|
auto const page_mask = page_size - 1;
|
||||||
|
|
||||||
|
auto const ptr = reinterpret_cast<uintptr_t>(address);
|
||||||
|
auto const aligned_ptr = ptr & (~page_mask);
|
||||||
|
auto const begin_size = ptr - aligned_ptr;
|
||||||
|
auto const page_count = (begin_size + size + page_mask) / page_size;
|
||||||
|
auto const aligned_size = page_count * page_size;
|
||||||
|
|
||||||
|
auto status = mprotect(reinterpret_cast<void*>(aligned_ptr), aligned_size, protection);
|
||||||
|
|
||||||
|
return status == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// i copied this function from the android linker
|
||||||
|
constexpr std::uint32_t elfhash(std::string_view name) {
|
||||||
|
std::uint32_t h = 0, g;
|
||||||
|
|
||||||
|
for (const auto c : name) {
|
||||||
|
h = (h << 4) + static_cast<unsigned char>(c);
|
||||||
|
g = h & 0xf0000000;
|
||||||
|
h ^= g;
|
||||||
|
h ^= g >> 24;
|
||||||
|
}
|
||||||
|
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
constexpr std::array<const std::uint8_t, 3> SYMBOL_PATCH_VALUE{":3"};
|
||||||
|
|
||||||
|
bool patch_symbol(std::uint32_t* hash_table, char* str_table, ElfW(Sym)* sym_table, const std::string_view orig_name) {
|
||||||
|
auto hash = elfhash(orig_name);
|
||||||
|
|
||||||
|
auto nbucket = hash_table[0];
|
||||||
|
auto nchain = hash_table[1];
|
||||||
|
auto bucket = &hash_table[2];
|
||||||
|
auto chain = &bucket[nbucket];
|
||||||
|
|
||||||
|
for (auto i = bucket[hash % nbucket]; i != 0; i = chain[i]) {
|
||||||
|
auto sym = sym_table + i;
|
||||||
|
auto sym_name = str_table + sym->st_name;
|
||||||
|
|
||||||
|
if (orig_name == sym_name) {
|
||||||
|
// there's probably no point to checking this, honestly
|
||||||
|
if (ELF_ST_BIND(sym->st_info) == STB_GLOBAL && ELF_ST_TYPE(sym->st_info) == STT_FUNC) {
|
||||||
|
// we found it! now go rename the symbol
|
||||||
|
|
||||||
|
log::debug("Disabling symbol at {} ({})", reinterpret_cast<void*>(sym_name), sym_name);
|
||||||
|
|
||||||
|
if (!set_mprotect(reinterpret_cast<std::uintptr_t>(sym_name), SYMBOL_PATCH_VALUE.size(), PROT_READ | PROT_WRITE)) {
|
||||||
|
log::warn("failed to patch symbol: could not set writable flag");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
memcpy(sym_name, SYMBOL_PATCH_VALUE.data(), SYMBOL_PATCH_VALUE.size());
|
||||||
|
|
||||||
|
if (!set_mprotect(reinterpret_cast<std::uintptr_t>(sym_name), SYMBOL_PATCH_VALUE.size(), PROT_READ)) {
|
||||||
|
log::warn("failed to patch symbol: could not remove writable flag");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log::warn("could not find symbol {} to patch (hash: {})", orig_name, hash);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool perform_symbol_patches(dl_phdr_info* info, const ElfDynamicState& state) {
|
||||||
|
// patch symbol names
|
||||||
|
auto has_error = false;
|
||||||
|
for (const auto symbol : remove_symbols) {
|
||||||
|
if (!patch_symbol(state.hash_table, state.str_table, state.sym_table, symbol)) {
|
||||||
|
has_error = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return !has_error;
|
||||||
|
}
|
||||||
|
|
||||||
|
#if defined(__LP64__)
|
||||||
|
#define ELF_R_SYM(i) ELF64_R_SYM(i)
|
||||||
|
#define ELF_R_TYPE(i) ELF64_R_TYPE(i)
|
||||||
|
#else
|
||||||
|
#define ELF_R_SYM(i) ELF32_R_SYM(i)
|
||||||
|
#define ELF_R_TYPE(i) ELF32_R_TYPE(i)
|
||||||
|
#endif
|
||||||
|
|
||||||
|
template <typename T, typename... Types>
|
||||||
|
concept is_one_of = (std::same_as<T, Types> || ...);
|
||||||
|
|
||||||
|
template <typename T> requires is_one_of<T, ElfW(Rela), ElfW(Rel)>
|
||||||
|
bool rewrite_symbol_reloc(std::uintptr_t addr, T& r, ElfW(Sym)* sym, void* func) {
|
||||||
|
auto type = ELF_R_TYPE(r.r_info);
|
||||||
|
|
||||||
|
// we're only interested in JUMP_SLOT symbols
|
||||||
|
if (type != R_AARCH64_JUMP_SLOT && type != R_ARM_JUMP_SLOT) {
|
||||||
|
log::warn("asked to patch unknown symbol type: {:#x}", type);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto write_addr = reinterpret_cast<std::uintptr_t*>(addr);
|
||||||
|
auto val = reinterpret_cast<std::uintptr_t>(func);
|
||||||
|
if constexpr (std::is_same_v<T, ElfW(Rela)>) {
|
||||||
|
// reloc = S + A
|
||||||
|
val += r.r_addend;
|
||||||
|
}
|
||||||
|
|
||||||
|
log::debug("Patching relocation at vaddr {:#x} (offset={:#x}): {:#x} => {:#x}", addr, r.r_offset, *write_addr, val);
|
||||||
|
|
||||||
|
if (!set_mprotect(addr, sizeof(std::uintptr_t), PROT_WRITE | PROT_READ)) {
|
||||||
|
log::warn("Failed to patch relocation: failed to add writable flag");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// why use memcpy when you can do this :)
|
||||||
|
*write_addr = val;
|
||||||
|
|
||||||
|
if (!set_mprotect(addr, sizeof(std::uintptr_t), PROT_READ)) {
|
||||||
|
log::warn("Failed to patch relocation: failed to remove writable flag");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
template <typename T> requires is_one_of<T, ElfW(Rela), ElfW(Rel)>
|
||||||
|
bool perform_reloc_patches(dl_phdr_info* info, const ElfDynamicState& state) {
|
||||||
|
auto table_size = state.plt_table_size_bytes / sizeof(T);
|
||||||
|
|
||||||
|
auto failed = false;
|
||||||
|
|
||||||
|
for (auto& plt : std::span{
|
||||||
|
reinterpret_cast<T*>(state.plt_table),
|
||||||
|
static_cast<std::size_t>(table_size)
|
||||||
|
}) {
|
||||||
|
auto sym = ELF_R_SYM(plt.r_info);
|
||||||
|
if (sym == 0) continue;
|
||||||
|
|
||||||
|
auto sym_data = state.sym_table + sym;
|
||||||
|
auto sym_name = state.str_table + sym_data->st_name;
|
||||||
|
|
||||||
|
auto reloc_addr = info->dlpi_addr + plt.r_offset;
|
||||||
|
|
||||||
|
if (strcmp(sym_name, "fopen") == 0) {
|
||||||
|
if (!rewrite_symbol_reloc(reloc_addr, plt, sym_data, reinterpret_cast<void*>(&fopen_hook))) {
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
} else if (strcmp(sym_name, "rename") == 0) {
|
||||||
|
if (!rewrite_symbol_reloc(reloc_addr, plt, sym_data, reinterpret_cast<void*>(&rename_hook))) {
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return !failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
int on_dl_iterate(dl_phdr_info* info, size_t size, void* data) {
|
||||||
|
// this is probably going to be gd
|
||||||
|
if (info->dlpi_name != nullptr && strstr(info->dlpi_name, "libcocos2dcpp.so") != nullptr) {
|
||||||
|
log::debug("Base address of libcocos2dcpp: {:#x}", info->dlpi_addr);
|
||||||
|
|
||||||
|
// step 1: get the dynamic table
|
||||||
|
std::uintptr_t dyn_addr = 0u;
|
||||||
|
|
||||||
|
for (auto& phdr : std::span{ info->dlpi_phdr, info->dlpi_phnum}) {
|
||||||
|
if (phdr.p_type == PT_DYNAMIC) {
|
||||||
|
dyn_addr = info->dlpi_addr + phdr.p_vaddr;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dyn_addr == 0u) {
|
||||||
|
log::warn("failed to find libcocos dynamic section");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// step 2: get the symbol table
|
||||||
|
auto dyn_entry = reinterpret_cast<ElfW(Dyn)*>(dyn_addr);
|
||||||
|
auto dyn_end_reached = false;
|
||||||
|
|
||||||
|
ElfDynamicState state{};
|
||||||
|
|
||||||
|
while (!dyn_end_reached) {
|
||||||
|
auto tag = dyn_entry->d_tag;
|
||||||
|
|
||||||
|
switch (dyn_entry->d_tag) {
|
||||||
|
case DT_SYMTAB:
|
||||||
|
state.sym_table = reinterpret_cast<ElfW(Sym)*>(info->dlpi_addr + dyn_entry->d_un.d_val);
|
||||||
|
break;
|
||||||
|
case DT_STRTAB:
|
||||||
|
state.str_table = reinterpret_cast<char*>(info->dlpi_addr + dyn_entry->d_un.d_val);
|
||||||
|
break;
|
||||||
|
case DT_HASH:
|
||||||
|
state.hash_table = reinterpret_cast<std::uint32_t*>(info->dlpi_addr + dyn_entry->d_un.d_val);
|
||||||
|
break;
|
||||||
|
case DT_SONAME:
|
||||||
|
state.soname_idx = dyn_entry->d_un.d_val;
|
||||||
|
break;
|
||||||
|
case DT_JMPREL:
|
||||||
|
state.plt_table = reinterpret_cast<void*>(info->dlpi_addr + dyn_entry->d_un.d_val);
|
||||||
|
break;
|
||||||
|
case DT_PLTREL: {
|
||||||
|
// for Android, arm64 = DT_RELA, arm32 = DT_REL
|
||||||
|
// could just gate this based on architecture but whatever
|
||||||
|
|
||||||
|
auto val = dyn_entry->d_un.d_val;
|
||||||
|
state.plt_table_rela = val == DT_RELA;
|
||||||
|
if (val != DT_RELA && val != DT_REL) {
|
||||||
|
log::warn("unrecognized PLTREL type {:#x}", val);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case DT_PLTRELSZ:
|
||||||
|
state.plt_table_size_bytes = dyn_entry->d_un.d_val;
|
||||||
|
break;
|
||||||
|
case DT_NULL:
|
||||||
|
dyn_end_reached = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
dyn_entry++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.is_empty()) {
|
||||||
|
log::error("failed to parse dynamic section (at least one table is null)");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool failed = false;
|
||||||
|
|
||||||
|
auto so_name = state.str_table + state.soname_idx;
|
||||||
|
if (strcmp(so_name, "libcocos2dcpp.so") != 0) {
|
||||||
|
log::warn("DT_SONAME gives us {}! that's not good", so_name);
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto reloc_failed = state.plt_table_rela
|
||||||
|
? !perform_reloc_patches<ElfW(Rela)>(info, state)
|
||||||
|
: !perform_reloc_patches<ElfW(Rel)>(info, state);
|
||||||
|
if (reloc_failed) {
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DataPaths::patch_exceptions) {
|
||||||
|
if (!perform_symbol_patches(info, state)) {
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failed) {
|
||||||
|
log::info(
|
||||||
|
"symbol patch diagnostics\nlibrary path: {}\naddrs: base={:#x} sym={} str={} hash={} plt={}",
|
||||||
|
info->dlpi_name,
|
||||||
|
info->dlpi_addr,
|
||||||
|
reinterpret_cast<void*>(state.sym_table),
|
||||||
|
reinterpret_cast<void*>(state.str_table),
|
||||||
|
reinterpret_cast<void*>(state.hash_table),
|
||||||
|
state.plt_table
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,35 +2,106 @@ package com.customRobTop
|
|||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.app.KeyguardManager
|
import android.content.ActivityNotFoundException
|
||||||
import android.content.*
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
import android.net.NetworkCapabilities
|
import android.net.NetworkCapabilities
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
import android.provider.OpenableColumns
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import android.util.Log
|
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import com.customRobTop.JniToCpp.resumeSound
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import com.customRobTop.contracts.ImportFileActivityContract
|
||||||
|
import com.customRobTop.contracts.ShareFileActivityContract
|
||||||
|
import com.geode.launcher.R
|
||||||
|
import com.geode.launcher.utils.DownloadUtils
|
||||||
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
|
import org.cocos2dx.lib.Cocos2dxGLSurfaceView
|
||||||
import org.cocos2dx.lib.Cocos2dxGLSurfaceView.Companion.closeIMEKeyboard
|
import org.cocos2dx.lib.Cocos2dxGLSurfaceView.Companion.closeIMEKeyboard
|
||||||
import org.cocos2dx.lib.Cocos2dxGLSurfaceView.Companion.openIMEKeyboard
|
import org.cocos2dx.lib.Cocos2dxGLSurfaceView.Companion.openIMEKeyboard
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.OutputStream
|
||||||
import java.lang.ref.WeakReference
|
import java.lang.ref.WeakReference
|
||||||
import java.util.*
|
import java.util.UUID
|
||||||
|
|
||||||
|
|
||||||
@Suppress("unused", "UNUSED_PARAMETER")
|
@Suppress("unused", "UNUSED_PARAMETER")
|
||||||
object BaseRobTopActivity {
|
object BaseRobTopActivity {
|
||||||
|
const val KEYBOARD_INPUT_MULTILINE = 1
|
||||||
|
const val KEYBOARD_INPUT_NUMERIC = 4
|
||||||
|
const val KEYBOARD_INPUT_PASSWORD = 2
|
||||||
|
const val LEVEL_EXTENSION: String = ".gdlvl"
|
||||||
|
|
||||||
private var isLoaded = false
|
private var isLoaded = false
|
||||||
var blockBackButton = false
|
var blockBackButton = false
|
||||||
private var keyboardActive = false
|
private var keyboardActive = false
|
||||||
|
var curKeyboardInputFlags = 0
|
||||||
|
|
||||||
var isPaused = false
|
var isPaused = false
|
||||||
|
|
||||||
lateinit var me: WeakReference<Activity>
|
lateinit var me: WeakReference<Activity>
|
||||||
private var shouldResumeSound = true
|
private var shouldResumeSound = true
|
||||||
|
|
||||||
fun setCurrentActivity(currentActivity: Activity) {
|
private var pendingExportPaths = emptyList<String>()
|
||||||
|
|
||||||
|
private lateinit var importFileActivityContract: ActivityResultLauncher<Unit>
|
||||||
|
private lateinit var shareFileActivityContract: ActivityResultLauncher<String>
|
||||||
|
private lateinit var shareFolderActivityContract: ActivityResultLauncher<Uri?>
|
||||||
|
|
||||||
|
fun setCurrentActivity(currentActivity: AppCompatActivity) {
|
||||||
me = WeakReference(currentActivity)
|
me = WeakReference(currentActivity)
|
||||||
|
|
||||||
|
importFileActivityContract = currentActivity.registerForActivityResult(ImportFileActivityContract()) { uris ->
|
||||||
|
val filePaths = copyPickedFiles(uris)
|
||||||
|
|
||||||
|
Cocos2dxGLSurfaceView.cocos2dxGLSurfaceView.queueEvent {
|
||||||
|
JniToCpp.filesPicked(filePaths.toTypedArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shareFileActivityContract = currentActivity.registerForActivityResult(ShareFileActivityContract()) { uri ->
|
||||||
|
val currentlyPending = pendingExportPaths
|
||||||
|
pendingExportPaths = emptyList()
|
||||||
|
|
||||||
|
val exportPath = currentlyPending.firstOrNull()
|
||||||
|
if (exportPath == null || uri == null) {
|
||||||
|
Cocos2dxGLSurfaceView.cocos2dxGLSurfaceView.queueEvent {
|
||||||
|
JniToCpp.fileShareFinished(false)
|
||||||
|
}
|
||||||
|
return@registerForActivityResult
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = saveToDocument(exportPath, uri)
|
||||||
|
Cocos2dxGLSurfaceView.cocos2dxGLSurfaceView.queueEvent {
|
||||||
|
JniToCpp.fileShareFinished(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shareFolderActivityContract = currentActivity.registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
|
||||||
|
val currentlyPending = pendingExportPaths
|
||||||
|
pendingExportPaths = emptyList()
|
||||||
|
|
||||||
|
if (uri == null) {
|
||||||
|
Cocos2dxGLSurfaceView.cocos2dxGLSurfaceView.queueEvent {
|
||||||
|
JniToCpp.fileShareFinished(false)
|
||||||
|
}
|
||||||
|
return@registerForActivityResult
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = saveToFolder(currentlyPending, uri) > 0
|
||||||
|
Cocos2dxGLSurfaceView.cocos2dxGLSurfaceView.queueEvent {
|
||||||
|
JniToCpp.fileShareFinished(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("HardwareIds")
|
@SuppressLint("HardwareIds")
|
||||||
@@ -63,33 +134,52 @@ object BaseRobTopActivity {
|
|||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun openURL(url: String) {
|
fun openURL(url: String) {
|
||||||
Log.d("MAIN", "Open URL")
|
|
||||||
me.get()?.runOnUiThread {
|
me.get()?.runOnUiThread {
|
||||||
try {
|
try {
|
||||||
me.get()?.startActivity(
|
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
|
||||||
Intent(
|
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
"android.intent.action.VIEW",
|
me.get()?.startActivity(intent)
|
||||||
Uri.parse(url)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} catch (e: ActivityNotFoundException) {
|
} catch (e: ActivityNotFoundException) {
|
||||||
Toast.makeText(
|
Toast.makeText(
|
||||||
me.get(),
|
me.get(),
|
||||||
"No activity found to open this URL.",
|
R.string.no_activity_found,
|
||||||
Toast.LENGTH_SHORT,
|
Toast.LENGTH_SHORT,
|
||||||
).show()
|
).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun copyToClipboard(str: String) {
|
||||||
|
val context = me.get() ?: return
|
||||||
|
context.runOnUiThread {
|
||||||
|
val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
clipboardManager.setPrimaryClip(
|
||||||
|
ClipData.newPlainText("text", str)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun getClipboardString(): String {
|
||||||
|
me.get()?.run {
|
||||||
|
val manager = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
val clip = manager.primaryClip
|
||||||
|
if (clip != null && clip.itemCount > 0) {
|
||||||
|
return clip.getItemAt(0).coerceToText(this).toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun sendMail(subject: String, body: String, to: String) {
|
fun sendMail(subject: String, body: String, to: String) {
|
||||||
me.get()?.runOnUiThread {
|
me.get()?.runOnUiThread {
|
||||||
val i = Intent("android.intent.action.SEND")
|
val i = Intent(Intent.ACTION_SEND)
|
||||||
i.type = "message/rfc822"
|
i.type = "message/rfc822"
|
||||||
i.putExtra("android.intent.extra.EMAIL", arrayOf(to))
|
i.putExtra(Intent.EXTRA_EMAIL, arrayOf(to))
|
||||||
i.putExtra("android.intent.extra.SUBJECT", subject)
|
i.putExtra(Intent.EXTRA_SUBJECT, subject)
|
||||||
i.putExtra("android.intent.extra.TEXT", body)
|
i.putExtra(Intent.EXTRA_TEXT, body)
|
||||||
try {
|
try {
|
||||||
me.get()?.startActivity(Intent.createChooser(i, "Send mail..."))
|
me.get()?.startActivity(Intent.createChooser(i, "Send mail..."))
|
||||||
} catch (e: ActivityNotFoundException) {
|
} catch (e: ActivityNotFoundException) {
|
||||||
@@ -260,28 +350,163 @@ object BaseRobTopActivity {
|
|||||||
)
|
)
|
||||||
fun logEvent(event: String) {}
|
fun logEvent(event: String) {}
|
||||||
|
|
||||||
class ReceiverScreen : BroadcastReceiver() {
|
@JvmStatic
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
fun setKeyboardInputFlags(flags: Int) {
|
||||||
when (intent.action) {
|
curKeyboardInputFlags = flags
|
||||||
Intent.ACTION_SCREEN_ON -> {
|
|
||||||
Log.d("TAG", "ACTION_SCREEN_ON")
|
|
||||||
if (!(me.get()?.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager).isKeyguardLocked) {
|
|
||||||
shouldResumeSound = true
|
|
||||||
}
|
}
|
||||||
if (!isPaused && shouldResumeSound) {
|
|
||||||
resumeSound()
|
@JvmStatic
|
||||||
}
|
fun pickImportFiles() {
|
||||||
}
|
try {
|
||||||
Intent.ACTION_SCREEN_OFF -> {
|
importFileActivityContract.launch(Unit)
|
||||||
shouldResumeSound = false
|
} catch (_: ActivityNotFoundException) {
|
||||||
}
|
me.get()?.apply {
|
||||||
Intent.ACTION_USER_PRESENT -> {
|
runOnUiThread {
|
||||||
shouldResumeSound = true
|
Toast.makeText(
|
||||||
if (!isPaused) {
|
this,
|
||||||
resumeSound()
|
R.string.no_activity_found,
|
||||||
|
Toast.LENGTH_SHORT,
|
||||||
|
).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun copyPickedFiles(uris: List<Uri>): List<String> {
|
||||||
|
val me = me.get() ?: return emptyList()
|
||||||
|
|
||||||
|
val importsDir = File(LaunchUtils.getSaveDirectory(me), "imports")
|
||||||
|
importsDir.mkdirs()
|
||||||
|
|
||||||
|
return uris.mapIndexedNotNull { index, uri ->
|
||||||
|
val displayName = displayName(uri)
|
||||||
|
?: "import_$index$LEVEL_EXTENSION"
|
||||||
|
if (!displayName.endsWith(LEVEL_EXTENSION)) {
|
||||||
|
return@mapIndexedNotNull null
|
||||||
|
}
|
||||||
|
|
||||||
|
var outputFile = File(importsDir, displayName)
|
||||||
|
var counter = 2
|
||||||
|
while (outputFile.exists()) {
|
||||||
|
outputFile = File(importsDir, "${counter}_$displayName")
|
||||||
|
counter++
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
me.contentResolver.openInputStream(uri)?.use { input ->
|
||||||
|
outputFile.outputStream().use { output ->
|
||||||
|
DownloadUtils.copyFile(input, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
outputFile.absolutePath
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
outputFile.delete()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun displayName(uri: Uri): String? {
|
||||||
|
me.get()?.contentResolver?.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursorQuery ->
|
||||||
|
if (cursorQuery.moveToFirst()) {
|
||||||
|
val displayName = cursorQuery.getString(0)
|
||||||
|
if (displayName.isNotEmpty()) {
|
||||||
|
return File(displayName).name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun shareFiles(files: Array<String>) {
|
||||||
|
pendingExportPaths = files.toList()
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (files.size == 1) {
|
||||||
|
shareFileActivityContract.launch(files.first())
|
||||||
|
return
|
||||||
|
} else if (files.size > 1) {
|
||||||
|
shareFolderActivityContract.launch(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
me.get()?.apply {
|
||||||
|
runOnUiThread {
|
||||||
|
Toast.makeText(
|
||||||
|
this,
|
||||||
|
R.string.no_activity_found,
|
||||||
|
Toast.LENGTH_SHORT,
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Cocos2dxGLSurfaceView.cocos2dxGLSurfaceView.queueEvent {
|
||||||
|
JniToCpp.fileShareFinished(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveToFolder(exportPaths: List<String>, treeUri: Uri): Int {
|
||||||
|
val uri = DocumentsContract.buildDocumentUriUsingTree(treeUri, DocumentsContract.getTreeDocumentId(treeUri))
|
||||||
|
|
||||||
|
val me = me.get() ?: return 0
|
||||||
|
|
||||||
|
return exportPaths.count { path ->
|
||||||
|
val filename = File(path).name
|
||||||
|
val documentUri = DocumentsContract.createDocument(me.contentResolver, uri, "application/octet-stream", filename)
|
||||||
|
?: return@count false
|
||||||
|
|
||||||
|
saveToDocument(path, documentUri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveToDocument(exportPath: String, uri: Uri): Boolean {
|
||||||
|
try {
|
||||||
|
FileInputStream(exportPath).use { inputStream ->
|
||||||
|
openOutput(uri).use { outputStream ->
|
||||||
|
if (outputStream != null) {
|
||||||
|
DownloadUtils.copyFile(inputStream, outputStream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureLevelExtension(uri)
|
||||||
|
return true
|
||||||
|
} catch (_: IOException) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openOutput(uri: Uri): OutputStream? {
|
||||||
|
val me = me.get() ?: return null
|
||||||
|
return try {
|
||||||
|
me.contentResolver.openOutputStream(uri, "wt")
|
||||||
|
} catch (_: IOException) {
|
||||||
|
me.contentResolver.openOutputStream(uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureLevelExtension(uri: Uri) {
|
||||||
|
val displayName = displayName(uri)
|
||||||
|
if (displayName == null || displayName.endsWith(LEVEL_EXTENSION)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val me = me.get() ?: return
|
||||||
|
|
||||||
|
val extensionIdx = displayName.lastIndexOf(".gdlvl.")
|
||||||
|
val substring = if (extensionIdx < 0) {
|
||||||
|
displayName + LEVEL_EXTENSION
|
||||||
|
} else {
|
||||||
|
displayName.substring(0, extensionIdx + 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
DocumentsContract.renameDocument(me.contentResolver, uri, substring)
|
||||||
|
} catch (_: IOException) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,4 +55,16 @@ object JniToCpp {
|
|||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
external fun videoAdShowed()
|
external fun videoAdShowed()
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
external fun filesPicked(files: Array<String>)
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
external fun fileShareFinished(success: Boolean)
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
external fun textInputCaretToEnd()
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
external fun textInputDone()
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.customRobTop.contracts
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.result.contract.ActivityResultContract
|
||||||
|
|
||||||
|
class ImportFileActivityContract : ActivityResultContract<Unit, List<Uri>>() {
|
||||||
|
override fun createIntent(
|
||||||
|
context: Context,
|
||||||
|
input: Unit
|
||||||
|
): Intent {
|
||||||
|
return Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||||
|
addCategory(Intent.CATEGORY_OPENABLE)
|
||||||
|
type = "*/*"
|
||||||
|
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun parseResult(
|
||||||
|
resultCode: Int,
|
||||||
|
intent: Intent?
|
||||||
|
): List<Uri> {
|
||||||
|
if (resultCode != Activity.RESULT_OK || intent == null) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
val clipData = intent.clipData
|
||||||
|
return if (clipData != null) {
|
||||||
|
List(clipData.itemCount) { i -> clipData.getItemAt(i).uri }
|
||||||
|
} else {
|
||||||
|
val intentData = intent.data
|
||||||
|
if (intentData != null)
|
||||||
|
listOf(intentData)
|
||||||
|
else
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.customRobTop.contracts
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
import androidx.activity.result.contract.ActivityResultContract
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class ShareFileActivityContract : ActivityResultContract<String, Uri?>() {
|
||||||
|
override fun createIntent(
|
||||||
|
context: Context,
|
||||||
|
input: String
|
||||||
|
): Intent {
|
||||||
|
return Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||||
|
addCategory(Intent.CATEGORY_OPENABLE)
|
||||||
|
type = "application/octet-stream"
|
||||||
|
putExtra(Intent.EXTRA_TITLE, File(input).name)
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
putExtra(DocumentsContract.EXTRA_INITIAL_URI,
|
||||||
|
DocumentsContract.buildDocumentUri("com.android.externalstorage.documents", "primary:Download")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun parseResult(
|
||||||
|
resultCode: Int,
|
||||||
|
intent: Intent?
|
||||||
|
): Uri? {
|
||||||
|
if (resultCode != Activity.RESULT_OK || intent == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return intent.data
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,525 +0,0 @@
|
|||||||
package com.geode.launcher
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.text.format.Formatter
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.compose.setContent
|
|
||||||
import androidx.activity.enableEdgeToEdge
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
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.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.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Info
|
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
|
||||||
import androidx.compose.material.icons.filled.Settings
|
|
||||||
import androidx.compose.material.icons.filled.Warning
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.FilledTonalButton
|
|
||||||
import androidx.compose.material3.FilledTonalIconButton
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
|
||||||
import androidx.compose.material3.LocalTextStyle
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
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.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import com.geode.launcher.main.*
|
|
||||||
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.GamePackageUtils
|
|
||||||
import com.geode.launcher.utils.LaunchUtils
|
|
||||||
import com.geode.launcher.utils.PreferenceUtils
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
class AltMainActivity : ComponentActivity() {
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
|
|
||||||
enableEdgeToEdge()
|
|
||||||
clearDownloadedApks(this)
|
|
||||||
|
|
||||||
val gdInstalled = GamePackageUtils.isGameInstalled(packageManager)
|
|
||||||
val geodeInstalled = LaunchUtils.isGeodeInstalled(this)
|
|
||||||
|
|
||||||
val returnMessage = intent.extras?.getString(LaunchUtils.LAUNCHER_KEY_RETURN_MESSAGE)
|
|
||||||
val returnExtendedMessage = intent.extras?.getString(LaunchUtils.LAUNCHER_KEY_RETURN_EXTENDED_MESSAGE)
|
|
||||||
|
|
||||||
@Suppress("DEPRECATION") // the new api is android 13 only (why)
|
|
||||||
val returnError = intent.extras?.getSerializable(LaunchUtils.LAUNCHER_KEY_RETURN_ERROR)
|
|
||||||
as? LaunchUtils.LauncherError
|
|
||||||
|
|
||||||
val loadFailureInfo = if (returnError != null && returnMessage != null) {
|
|
||||||
LoadFailureInfo(returnError, returnMessage, returnExtendedMessage)
|
|
||||||
} else if (LaunchUtils.lastSessionCrashed(this)) {
|
|
||||||
LoadFailureInfo(LaunchUtils.LauncherError.CRASHED)
|
|
||||||
} else { null }
|
|
||||||
|
|
||||||
|
|
||||||
setContent {
|
|
||||||
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
|
|
||||||
val theme = Theme.fromInt(themeOption)
|
|
||||||
|
|
||||||
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
|
|
||||||
|
|
||||||
val launchViewModel = viewModel<LaunchViewModel>(factory = LaunchViewModel.Factory)
|
|
||||||
|
|
||||||
if (loadFailureInfo != null) {
|
|
||||||
launchViewModel.loadFailure = loadFailureInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
CompositionLocalProvider(LocalTheme provides theme) {
|
|
||||||
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption) {
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
) {
|
|
||||||
AltMainScreen(launchViewModel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (gdInstalled && geodeInstalled) {
|
|
||||||
intent.getBooleanExtra("restarted", false).let {
|
|
||||||
if (it) {
|
|
||||||
onLaunch(this)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class LaunchStatusInfo(
|
|
||||||
val title: String,
|
|
||||||
val details: String? = null,
|
|
||||||
val progress: (() -> Float)? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun LaunchCancelledIcon(cancelReason: LaunchViewModel.LaunchCancelReason, modifier: Modifier = Modifier) {
|
|
||||||
when (cancelReason) {
|
|
||||||
LaunchViewModel.LaunchCancelReason.GAME_OUTDATED,
|
|
||||||
LaunchViewModel.LaunchCancelReason.GAME_MISSING -> Icon(painterResource(R.drawable.icon_error), contentDescription = null, modifier)
|
|
||||||
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED,
|
|
||||||
LaunchViewModel.LaunchCancelReason.GEODE_NOT_FOUND -> Icon(Icons.Default.Warning, contentDescription = null, modifier)
|
|
||||||
else -> Icon(Icons.Default.Info, contentDescription = null, modifier)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun mapCancelReasonToInfo(cancelReason: LaunchViewModel.LaunchCancelReason): LaunchStatusInfo {
|
|
||||||
return when (cancelReason) {
|
|
||||||
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED -> LaunchStatusInfo(
|
|
||||||
title = stringResource(R.string.launcher_cancelled_crash)
|
|
||||||
)
|
|
||||||
LaunchViewModel.LaunchCancelReason.GEODE_NOT_FOUND -> LaunchStatusInfo(
|
|
||||||
title = stringResource(R.string.geode_download_title)
|
|
||||||
)
|
|
||||||
LaunchViewModel.LaunchCancelReason.GAME_MISSING -> LaunchStatusInfo(
|
|
||||||
title = stringResource(R.string.launcher_cancelled_error),
|
|
||||||
details = stringResource(R.string.game_not_found)
|
|
||||||
)
|
|
||||||
LaunchViewModel.LaunchCancelReason.GAME_OUTDATED -> LaunchStatusInfo(
|
|
||||||
title = stringResource(R.string.launcher_cancelled_error),
|
|
||||||
details = stringResource(R.string.launcher_cancelled_outdated)
|
|
||||||
)
|
|
||||||
LaunchViewModel.LaunchCancelReason.AUTOMATIC -> LaunchStatusInfo(
|
|
||||||
title = stringResource(R.string.launcher_cancelled_navigated_away)
|
|
||||||
)
|
|
||||||
else -> LaunchStatusInfo(
|
|
||||||
title = stringResource(R.string.launcher_cancelled_manual)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun mapLaunchStatusToInfo(state: LaunchViewModel.LaunchUIState, inSafeMode: Boolean = false): LaunchStatusInfo {
|
|
||||||
return when (state) {
|
|
||||||
is LaunchViewModel.LaunchUIState.Initial,
|
|
||||||
is LaunchViewModel.LaunchUIState.Working,
|
|
||||||
is LaunchViewModel.LaunchUIState.Ready -> LaunchStatusInfo(
|
|
||||||
title = stringResource(R.string.launcher_starting_game),
|
|
||||||
details = if (inSafeMode) stringResource(R.string.launcher_in_safe_mode) else null
|
|
||||||
)
|
|
||||||
is LaunchViewModel.LaunchUIState.UpdateCheck -> LaunchStatusInfo(
|
|
||||||
title = stringResource(R.string.release_fetch_in_progress)
|
|
||||||
)
|
|
||||||
is LaunchViewModel.LaunchUIState.Updating -> {
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
val downloaded = remember(state.downloaded) {
|
|
||||||
Formatter.formatShortFileSize(context, state.downloaded)
|
|
||||||
}
|
|
||||||
|
|
||||||
val outOf = remember(state.outOf) {
|
|
||||||
Formatter.formatShortFileSize(context, state.outOf)
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchStatusInfo(
|
|
||||||
title = stringResource(R.string.launcher_downloading_update),
|
|
||||||
details = stringResource(R.string.launcher_downloading_update_details, downloaded, outOf),
|
|
||||||
progress = {
|
|
||||||
val progress = state.downloaded / state.outOf.toDouble()
|
|
||||||
progress.toFloat()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
is LaunchViewModel.LaunchUIState.Cancelled -> {
|
|
||||||
return mapCancelReasonToInfo(state.reason)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun LaunchCancelledBody(statusInfo: LaunchStatusInfo, icon: @Composable () -> Unit, inProgress: Boolean, modifier: Modifier = Modifier) {
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
horizontalAlignment = Alignment.Start,
|
|
||||||
modifier = modifier.width(300.dp)
|
|
||||||
) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodyLarge) {
|
|
||||||
icon()
|
|
||||||
Spacer(Modifier.size(8.dp))
|
|
||||||
Text(statusInfo.title)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inProgress) {
|
|
||||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
|
||||||
}
|
|
||||||
|
|
||||||
if (statusInfo.details != null) {
|
|
||||||
Text(
|
|
||||||
statusInfo.details,
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun LaunchProgressBody(statusInfo: LaunchStatusInfo, modifier: Modifier = Modifier) {
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
horizontalAlignment = Alignment.Start,
|
|
||||||
modifier = modifier.width(300.dp)
|
|
||||||
) {
|
|
||||||
Text(statusInfo.title, style = MaterialTheme.typography.bodyLarge)
|
|
||||||
|
|
||||||
if (statusInfo.progress != null) {
|
|
||||||
LinearProgressIndicator(
|
|
||||||
statusInfo.progress,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
|
||||||
}
|
|
||||||
|
|
||||||
if (statusInfo.details != null) {
|
|
||||||
Text(statusInfo.details, style = MaterialTheme.typography.bodyMedium)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun RetryButtonContents(reason: LaunchViewModel.LaunchCancelReason) {
|
|
||||||
when (reason) {
|
|
||||||
LaunchViewModel.LaunchCancelReason.MANUAL -> {
|
|
||||||
Icon(
|
|
||||||
Icons.Filled.Refresh,
|
|
||||||
contentDescription = null
|
|
||||||
)
|
|
||||||
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
|
||||||
Text(stringResource(R.string.launcher_cancelled_restart))
|
|
||||||
}
|
|
||||||
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED,
|
|
||||||
LaunchViewModel.LaunchCancelReason.AUTOMATIC -> {
|
|
||||||
Icon(
|
|
||||||
painterResource(R.drawable.icon_resume),
|
|
||||||
contentDescription = null
|
|
||||||
)
|
|
||||||
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
|
||||||
Text(stringResource(R.string.launcher_cancelled_resume))
|
|
||||||
}
|
|
||||||
LaunchViewModel.LaunchCancelReason.GEODE_NOT_FOUND -> {
|
|
||||||
Icon(
|
|
||||||
Icons.Filled.Refresh,
|
|
||||||
contentDescription = null
|
|
||||||
)
|
|
||||||
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
|
||||||
Text(stringResource(R.string.launcher_cancelled_retry))
|
|
||||||
}
|
|
||||||
// these shouldn't be allowing retries anyways
|
|
||||||
LaunchViewModel.LaunchCancelReason.GAME_MISSING,
|
|
||||||
LaunchViewModel.LaunchCancelReason.GAME_OUTDATED -> {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
|
||||||
@Composable
|
|
||||||
fun LaunchProgressCard(
|
|
||||||
uiState: LaunchViewModel.LaunchUIState,
|
|
||||||
crashInfo: LoadFailureInfo?,
|
|
||||||
onCancel: () -> Unit,
|
|
||||||
onResume: (safeMode: Boolean) -> Unit,
|
|
||||||
onMore: () -> Unit,
|
|
||||||
extraOptions: @Composable () -> Unit,
|
|
||||||
safeModeEnabled: Boolean,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
var showSafeModeDialog by remember { mutableStateOf(false) }
|
|
||||||
val status = mapLaunchStatusToInfo(uiState, safeModeEnabled)
|
|
||||||
|
|
||||||
Card(modifier = modifier.width(300.dp)) {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.padding(16.dp)) {
|
|
||||||
if (uiState is LaunchViewModel.LaunchUIState.Cancelled) {
|
|
||||||
LaunchCancelledBody(
|
|
||||||
statusInfo = status,
|
|
||||||
icon = { LaunchCancelledIcon(uiState.reason, Modifier.size(32.dp)) },
|
|
||||||
inProgress = uiState.inProgress
|
|
||||||
)
|
|
||||||
|
|
||||||
if (uiState.reason == LaunchViewModel.LaunchCancelReason.GAME_OUTDATED) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
// game should be downloaded if this message is showing
|
|
||||||
val showDownload = remember {
|
|
||||||
!GamePackageUtils.identifyGameLegitimacy(context.packageManager)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showDownload) {
|
|
||||||
GooglePlayBadge(modifier = Modifier.align(Alignment.CenterHorizontally))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (uiState.reason.allowsRetry()) {
|
|
||||||
FlowRow(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
modifier = Modifier.width(300.dp)
|
|
||||||
) {
|
|
||||||
LongPressButton(onClick = { onResume(false) }, onLongPress = {
|
|
||||||
showSafeModeDialog = true
|
|
||||||
}) {
|
|
||||||
RetryButtonContents(uiState.reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.weight(1.0f))
|
|
||||||
|
|
||||||
if (crashInfo != null) {
|
|
||||||
IconButton(onClick = onMore) {
|
|
||||||
Icon(
|
|
||||||
painterResource(R.drawable.icon_question_mark),
|
|
||||||
contentDescription = stringResource(R.string.launcher_cancelled_help_alt)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extraOptions()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LaunchProgressBody(statusInfo = status)
|
|
||||||
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.width(300.dp)) {
|
|
||||||
TextButton(onClick = onCancel) {
|
|
||||||
Text(stringResource(R.string.release_fetch_button_cancel))
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.weight(1.0f))
|
|
||||||
|
|
||||||
extraOptions()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showSafeModeDialog) {
|
|
||||||
SafeModeDialog(
|
|
||||||
onDismiss = {
|
|
||||||
showSafeModeDialog = false
|
|
||||||
},
|
|
||||||
onLaunch = {
|
|
||||||
onResume(true)
|
|
||||||
showSafeModeDialog = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ExtraOptions(onSettings: () -> Unit) {
|
|
||||||
IconButton(
|
|
||||||
onClick = onSettings,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Filled.Settings,
|
|
||||||
contentDescription = stringResource(R.string.launcher_settings_icon_alt)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun GeodeLogo(modifier: Modifier = Modifier) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.Center,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = modifier
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
painter = painterResource(id = R.drawable.geode_logo),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(76.dp, 76.dp)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.launcher_title),
|
|
||||||
style = MaterialTheme.typography.displayMedium,
|
|
||||||
modifier = Modifier.padding(12.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun AltMainScreen(
|
|
||||||
launchViewModel: LaunchViewModel = viewModel(factory = LaunchViewModel.Factory)
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
launchViewModel.beginLaunchFlow()
|
|
||||||
}
|
|
||||||
|
|
||||||
val launchUIState by launchViewModel.uiState.collectAsState()
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
|
||||||
var launchInSafeMode by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
var showErrorInfo by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.safeDrawingPadding(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(32.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
modifier = Modifier
|
|
||||||
.height(350.dp)
|
|
||||||
.padding(8.dp)
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
) {
|
|
||||||
GeodeLogo()
|
|
||||||
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
LaunchProgressCard(
|
|
||||||
launchUIState,
|
|
||||||
crashInfo = launchViewModel.currentCrashInfo(),
|
|
||||||
onCancel = {
|
|
||||||
launchInSafeMode = false
|
|
||||||
coroutineScope.launch {
|
|
||||||
launchViewModel.cancelLaunch()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onResume = { safeMode ->
|
|
||||||
launchInSafeMode = safeMode
|
|
||||||
|
|
||||||
launchViewModel.clearCrashInfo()
|
|
||||||
coroutineScope.launch {
|
|
||||||
launchViewModel.beginLaunchFlow(true)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onMore = {
|
|
||||||
showErrorInfo = true
|
|
||||||
},
|
|
||||||
extraOptions = {
|
|
||||||
ExtraOptions(
|
|
||||||
onSettings = {
|
|
||||||
coroutineScope.launch {
|
|
||||||
launchViewModel.cancelLaunch(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
onSettings(context)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
safeModeEnabled = launchInSafeMode
|
|
||||||
)
|
|
||||||
|
|
||||||
val nextLauncherUpdate by launchViewModel.nextLauncherUpdate.collectAsState()
|
|
||||||
|
|
||||||
// only show launcher update in a case where a user won't see it ingame
|
|
||||||
if (nextLauncherUpdate != null && launchUIState is LaunchViewModel.LaunchUIState.Cancelled) {
|
|
||||||
StatusIndicator(
|
|
||||||
icon = {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Info,
|
|
||||||
contentDescription = null
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
installLauncherUpdate(context)
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.launcher_update_available),
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (launchUIState is LaunchViewModel.LaunchUIState.Ready) {
|
|
||||||
UpdateWarning(launchInSafeMode) {
|
|
||||||
launchInSafeMode = false
|
|
||||||
coroutineScope.launch {
|
|
||||||
launchViewModel.cancelLaunch()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val loadFailure = launchViewModel.currentCrashInfo()
|
|
||||||
if (showErrorInfo && loadFailure != null) {
|
|
||||||
ErrorInfoSheet(loadFailure, onDismiss = { showErrorInfo = false })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +1,33 @@
|
|||||||
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.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.compose.ui.platform.ComposeView
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.core.view.WindowInsetsControllerCompat
|
import androidx.core.view.WindowInsetsControllerCompat
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
import com.customRobTop.BaseRobTopActivity
|
import com.customRobTop.BaseRobTopActivity
|
||||||
import com.customRobTop.JniToCpp
|
import com.customRobTop.JniToCpp
|
||||||
import com.geode.launcher.main.LaunchNotification
|
import com.geode.launcher.main.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.ConstrainedFrameLayout
|
||||||
import com.geode.launcher.utils.DownloadUtils
|
import com.geode.launcher.utils.DownloadUtils
|
||||||
@@ -30,7 +35,6 @@ 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.LaunchUtils
|
||||||
import com.geode.launcher.utils.PreferenceUtils
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
import org.cocos2dx.lib.Cocos2dxEditText
|
|
||||||
import org.cocos2dx.lib.Cocos2dxGLSurfaceView
|
import org.cocos2dx.lib.Cocos2dxGLSurfaceView
|
||||||
import org.cocos2dx.lib.Cocos2dxHelper
|
import org.cocos2dx.lib.Cocos2dxHelper
|
||||||
import org.cocos2dx.lib.Cocos2dxRenderer
|
import org.cocos2dx.lib.Cocos2dxRenderer
|
||||||
@@ -39,6 +43,9 @@ import java.io.File
|
|||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import javax.microedition.khronos.egl.EGL10
|
||||||
|
import javax.microedition.khronos.egl.EGLConfig
|
||||||
|
import javax.microedition.khronos.egl.EGLDisplay
|
||||||
|
|
||||||
enum class DisplayMode {
|
enum class DisplayMode {
|
||||||
DEFAULT, LIMITED, FULLSCREEN;
|
DEFAULT, LIMITED, FULLSCREEN;
|
||||||
@@ -52,19 +59,29 @@ enum class DisplayMode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
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 displayMode = DisplayMode.DEFAULT
|
||||||
private var forceRefreshRate = false
|
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()
|
setupUIState()
|
||||||
|
FMOD.init(this)
|
||||||
|
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
@@ -88,6 +105,8 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
val errorMessage = when {
|
val errorMessage = when {
|
||||||
abiMismatch && is64bit -> LaunchUtils.LauncherError.LINKER_NEEDS_32BIT
|
abiMismatch && is64bit -> LaunchUtils.LauncherError.LINKER_NEEDS_32BIT
|
||||||
abiMismatch -> LaunchUtils.LauncherError.LINKER_NEEDS_64BIT
|
abiMismatch -> LaunchUtils.LauncherError.LINKER_NEEDS_64BIT
|
||||||
|
e.message?.contains("__gxx_personality_v0") == true ->
|
||||||
|
LaunchUtils.LauncherError.LINKER_FAILED_STL
|
||||||
else -> LaunchUtils.LauncherError.LINKER_FAILED
|
else -> LaunchUtils.LauncherError.LINKER_FAILED
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +124,13 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||||
|
override fun handleOnBackPressed() {
|
||||||
|
mGLSurfaceView?.sendKeyBack()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
mGLSurfaceView?.manualBackEvents = true
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createVersionFile() {
|
private fun createVersionFile() {
|
||||||
@@ -119,7 +145,7 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
returnMessage: String? = null,
|
returnMessage: String? = null,
|
||||||
returnExtendedMessage: String? = null
|
returnExtendedMessage: String? = null
|
||||||
) {
|
) {
|
||||||
val launchIntent = Intent(this, MainActivity::class.java).apply {
|
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)!!.apply {
|
||||||
flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
|
||||||
if (error != null && !returnMessage.isNullOrEmpty()) {
|
if (error != null && !returnMessage.isNullOrEmpty()) {
|
||||||
@@ -145,30 +171,88 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
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 (GamePackageUtils.getGameVersionCode(packageManager) >= 39L) {
|
val gameVersionCode = GamePackageUtils.getGameVersionCode(packageManager)
|
||||||
val customSymbols = PreferenceUtils.get(this).getBoolean(PreferenceUtils.Key.CUSTOM_SYMBOL_LIST)
|
if (gameVersionCode >= 39L) {
|
||||||
if (customSymbols) {
|
|
||||||
val symbolFile = File(LaunchUtils.getBaseDirectory(this), "exception_symbols.txt")
|
|
||||||
LauncherFix.enableCustomSymbolList(symbolFile.path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// this fix requires geode v3, which is 2.206+
|
// 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
|
// there is a short period in which 2.206 users will still have geode v2, but whatever. ig
|
||||||
LauncherFix.performExceptionsRenaming()
|
LauncherFix.enableExceptionsRenaming()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LauncherFix.performPatches()
|
||||||
|
|
||||||
loadInternalMods()
|
loadInternalMods()
|
||||||
|
|
||||||
setContentView(createView())
|
val baseView = createView()
|
||||||
|
if (gameVersionCode >= 42L) {
|
||||||
|
mGLSurfaceView?.sendNewCocosEvents = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!GeodeUtils.handleSafeArea && Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
|
||||||
|
val frameLayoutParams = ViewGroup.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
)
|
||||||
|
val frameLayout = FrameLayout(this)
|
||||||
|
frameLayout.layoutParams = frameLayoutParams
|
||||||
|
|
||||||
|
frameLayout.setOnApplyWindowInsetsListener { v, insets ->
|
||||||
|
val displayInsets = insets.displayCutout
|
||||||
|
if (displayInsets != null) {
|
||||||
|
v.updatePadding(
|
||||||
|
left = displayInsets.safeInsetLeft,
|
||||||
|
top = displayInsets.safeInsetTop,
|
||||||
|
right = displayInsets.safeInsetRight,
|
||||||
|
bottom = displayInsets.safeInsetBottom
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
v.updatePadding(
|
||||||
|
left = 0, top = 0, right = 0, bottom = 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Cocos2dxGLSurfaceView.onKeyboardInsets(insets)
|
||||||
|
|
||||||
|
WindowInsets.CONSUMED
|
||||||
|
}
|
||||||
|
|
||||||
|
frameLayout.addView(baseView)
|
||||||
|
setContentView(frameLayout)
|
||||||
|
} else {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
baseView.setOnApplyWindowInsetsListener { view, insets ->
|
||||||
|
Cocos2dxGLSurfaceView.onKeyboardInsets(insets)
|
||||||
|
return@setOnApplyWindowInsetsListener view.onApplyWindowInsets(insets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setContentView(baseView)
|
||||||
|
}
|
||||||
|
|
||||||
setupPostLibraryLoad(gdPackageInfo)
|
setupPostLibraryLoad(gdPackageInfo)
|
||||||
|
|
||||||
try {
|
val geodeFailed = try {
|
||||||
loadGeodeLibrary()
|
loadGeodeLibrary()
|
||||||
|
false
|
||||||
} catch (e: UnsatisfiedLinkError) {
|
} catch (e: UnsatisfiedLinkError) {
|
||||||
handleGeodeException(e)
|
handleGeodeException(e)
|
||||||
|
true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
handleGeodeException(e)
|
handleGeodeException(e)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
setupNotificationView(baseView, geodeFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupNotificationView(baseView: FrameLayout, geodeFailed: Boolean) {
|
||||||
|
val cards = determineDisplayedCards(this, geodeFailed)
|
||||||
|
if (cards.isNotEmpty()) {
|
||||||
|
val notificationView = ComposeView(this)
|
||||||
|
baseView.addView(notificationView)
|
||||||
|
|
||||||
|
notificationView.setContent {
|
||||||
|
LaunchNotification(cards)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,6 +278,9 @@ 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)
|
||||||
@@ -203,22 +290,19 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun setupPostLibraryLoad(packageInfo: PackageInfo) {
|
private fun setupPostLibraryLoad(packageInfo: PackageInfo) {
|
||||||
FMOD.init(this)
|
|
||||||
|
|
||||||
// call native functions after native libraries init
|
// call native functions after native libraries init
|
||||||
JniToCpp.setupHSSAssets(
|
JniToCpp.setupHSSAssets(
|
||||||
packageInfo.applicationInfo.sourceDir,
|
packageInfo.applicationInfo!!.sourceDir,
|
||||||
Environment.getExternalStorageDirectory().absolutePath
|
Environment.getExternalStorageDirectory().absolutePath
|
||||||
)
|
)
|
||||||
Cocos2dxHelper.nativeSetApkPath(packageInfo.applicationInfo.sourceDir)
|
Cocos2dxHelper.nativeSetApkPath(packageInfo.applicationInfo!!.sourceDir)
|
||||||
|
|
||||||
BaseRobTopActivity.setCurrentActivity(this)
|
BaseRobTopActivity.setCurrentActivity(this)
|
||||||
registerReceiver()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("UnsafeDynamicallyLoadedCode")
|
@SuppressLint("UnsafeDynamicallyLoadedCode")
|
||||||
private fun tryLoadLibrary(packageInfo: PackageInfo, libraryName: String) {
|
private fun tryLoadLibrary(packageInfo: PackageInfo, libraryName: String) {
|
||||||
val nativeDir = getNativeLibraryDirectory(packageInfo.applicationInfo)
|
val nativeDir = getNativeLibraryDirectory(packageInfo.applicationInfo!!)
|
||||||
val libraryPath = if (nativeDir.endsWith('/')) "${nativeDir}lib$libraryName.so" else "$nativeDir/lib$libraryName.so"
|
val libraryPath = if (nativeDir.endsWith('/')) "${nativeDir}lib$libraryName.so" else "$nativeDir/lib$libraryName.so"
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -309,8 +393,15 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
return "$sourceDir!/lib/$architecture/"
|
return "$sourceDir!/lib/$architecture/"
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("UnsafeDynamicallyLoadedCode")
|
|
||||||
private fun loadGeodeLibrary() {
|
private fun loadGeodeLibrary() {
|
||||||
|
@Suppress("SENSELESS_COMPARISON")
|
||||||
|
if (BuildConfig.PREBUNDLED_GEODE != null) {
|
||||||
|
// attempt to load an externally provided Geode first
|
||||||
|
if (!loadExternalGeode()) {
|
||||||
|
copyGeodeResources()
|
||||||
|
System.loadLibrary("geode")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
// 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 {
|
||||||
@@ -318,18 +409,29 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
System.loadLibrary("geode")
|
System.loadLibrary("geode")
|
||||||
return
|
return
|
||||||
} catch (e: UnsatisfiedLinkError) {
|
} catch (e: UnsatisfiedLinkError) {
|
||||||
|
if (!loadExternalGeode()) {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("UnsafeDynamicallyLoadedCode")
|
||||||
|
private fun loadExternalGeode(): Boolean {
|
||||||
// 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()) {
|
||||||
|
println("Loading Geode from ${geodePath.path}")
|
||||||
System.load(geodePath.path)
|
System.load(geodePath.path)
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)!!
|
val externalGeodePath = LaunchUtils.getInstalledGeodePath(this)
|
||||||
|
?: return false
|
||||||
|
|
||||||
val copiedPath = File(filesDir.path, "copied")
|
val copiedPath = File(filesDir.path, "copied")
|
||||||
if (copiedPath.exists()) {
|
if (copiedPath.exists()) {
|
||||||
@@ -346,14 +448,16 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (copiedGeodePath.exists()) {
|
if (copiedGeodePath.exists()) {
|
||||||
|
// android 17 fix
|
||||||
|
copiedGeodePath.setWritable(false)
|
||||||
|
|
||||||
println("Loading Geode from ${externalGeodePath.name}")
|
println("Loading Geode from ${externalGeodePath.name}")
|
||||||
System.load(copiedGeodePath.path)
|
System.load(copiedGeodePath.path)
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw e
|
return false
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createView(): FrameLayout {
|
private fun createView(): FrameLayout {
|
||||||
@@ -365,15 +469,22 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
frameLayout.layoutParams = frameLayoutParams
|
frameLayout.layoutParams = frameLayoutParams
|
||||||
|
|
||||||
if (displayMode == DisplayMode.LIMITED) {
|
if (displayMode == DisplayMode.LIMITED) {
|
||||||
// despite not being perfectly 16:9, this is what Android calls "16:9"
|
frameLayout.aspectRatio = mAspectRatio
|
||||||
frameLayout.aspectRatio = 1.86f
|
}
|
||||||
|
|
||||||
|
if (mScreenZoomFit && mScreenZoom != 1.0f) {
|
||||||
|
frameLayout.fitZoom = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mScreenZoom != 1.0f) {
|
||||||
|
frameLayout.zoom = mScreenZoom
|
||||||
}
|
}
|
||||||
|
|
||||||
val editTextLayoutParams = ViewGroup.LayoutParams(
|
val editTextLayoutParams = ViewGroup.LayoutParams(
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
)
|
)
|
||||||
val editText = Cocos2dxEditText(this)
|
val editText = AppCompatEditText(this)
|
||||||
editText.layoutParams = editTextLayoutParams
|
editText.layoutParams = editTextLayoutParams
|
||||||
frameLayout.addView(editText)
|
frameLayout.addView(editText)
|
||||||
|
|
||||||
@@ -382,49 +493,65 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
this.mGLSurfaceView = glSurfaceView
|
this.mGLSurfaceView = glSurfaceView
|
||||||
frameLayout.addView(this.mGLSurfaceView)
|
frameLayout.addView(this.mGLSurfaceView)
|
||||||
|
|
||||||
val showNotification = PreferenceUtils.get(this).getBoolean(PreferenceUtils.Key.ENABLE_REDESIGN)
|
|
||||||
if (showNotification) {
|
|
||||||
val notificationView = ComposeView(this)
|
|
||||||
frameLayout.addView(notificationView)
|
|
||||||
|
|
||||||
notificationView.setContent {
|
|
||||||
LaunchNotification()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
glSurfaceView.setEGLContextClientVersion(2)
|
glSurfaceView.setEGLContextClientVersion(2)
|
||||||
glSurfaceView.setEGLConfigChooser(5, 6, 5, 0, 16, 8)
|
glSurfaceView.setEGLConfigChooser(EGLConfigChooser())
|
||||||
|
|
||||||
if (isAndroidEmulator()) {
|
|
||||||
glSurfaceView.setEGLConfigChooser(8, 8, 8, 8, 16, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
glSurfaceView.initView()
|
glSurfaceView.initView()
|
||||||
|
|
||||||
val renderer = Cocos2dxRenderer(glSurfaceView)
|
val renderer = Cocos2dxRenderer(glSurfaceView)
|
||||||
glSurfaceView.setCocos2dxRenderer(renderer)
|
glSurfaceView.setCocos2dxRenderer(renderer)
|
||||||
|
|
||||||
if (forceRefreshRate) {
|
renderer.setFrameRate = mForceRefreshRate
|
||||||
renderer.setFrameRate = true
|
|
||||||
|
val frameRate = mLimitedRefreshRate
|
||||||
|
if (frameRate != null) {
|
||||||
|
renderer.limitFrameRate(frameRate)
|
||||||
}
|
}
|
||||||
|
|
||||||
editText.inputType = 145
|
editText.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
|
||||||
glSurfaceView.cocos2dxEditText = editText
|
glSurfaceView.cocos2dxEditText = editText
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
glSurfaceView.defaultFocusHighlightEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
return frameLayout
|
return frameLayout
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupUIState() {
|
private fun setupUIState() {
|
||||||
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE
|
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE
|
||||||
|
|
||||||
|
val preferenceUtils = PreferenceUtils.get(this)
|
||||||
|
|
||||||
displayMode = DisplayMode.fromInt(
|
displayMode = DisplayMode.fromInt(
|
||||||
PreferenceUtils.get(this).getInt(PreferenceUtils.Key.DISPLAY_MODE)
|
preferenceUtils.getInt(PreferenceUtils.Key.DISPLAY_MODE)
|
||||||
)
|
)
|
||||||
|
|
||||||
forceRefreshRate = PreferenceUtils.get(this).getBoolean(PreferenceUtils.Key.FORCE_HRR)
|
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) {
|
if (displayMode == DisplayMode.FULLSCREEN && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
window.attributes.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
window.attributes.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||||
|
GeodeUtils.handleSafeArea = true
|
||||||
|
} else {
|
||||||
|
GeodeUtils.handleSafeArea = false
|
||||||
}
|
}
|
||||||
|
|
||||||
hideSystemUi()
|
hideSystemUi()
|
||||||
@@ -452,19 +579,18 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
|
|
||||||
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,25 +621,6 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun registerReceiver() {
|
|
||||||
unregisterReceivers()
|
|
||||||
try {
|
|
||||||
val filter = IntentFilter(Intent.ACTION_SCREEN_ON)
|
|
||||||
filter.addAction(Intent.ACTION_SCREEN_OFF)
|
|
||||||
filter.addAction(Intent.ACTION_USER_PRESENT)
|
|
||||||
mReceiver = BaseRobTopActivity.ReceiverScreen()
|
|
||||||
registerReceiver(mReceiver, filter)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun unregisterReceivers() {
|
|
||||||
if (mReceiver != null) {
|
|
||||||
unregisterReceiver(mReceiver)
|
|
||||||
mReceiver = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun runOnGLThread(runnable: Runnable) {
|
override fun runOnGLThread(runnable: Runnable) {
|
||||||
mGLSurfaceView?.queueEvent(runnable)
|
mGLSurfaceView?.queueEvent(runnable)
|
||||||
}
|
}
|
||||||
@@ -540,18 +647,6 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
TODO("Not yet implemented")
|
TODO("Not yet implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isAndroidEmulator(): Boolean {
|
|
||||||
Log.d(sTag, "model=" + Build.MODEL)
|
|
||||||
val product = Build.PRODUCT
|
|
||||||
Log.d(sTag, "product=$product")
|
|
||||||
var isEmulator = false
|
|
||||||
if (product != null) {
|
|
||||||
isEmulator = product == "sdk" || product.contains("_sdk") || product.contains("sdk_")
|
|
||||||
}
|
|
||||||
Log.d(sTag, "isEmulator=$isEmulator")
|
|
||||||
return isEmulator
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCapabilityAdded(capability: String): Boolean {
|
override fun onCapabilityAdded(capability: String): Boolean {
|
||||||
return when (capability) {
|
return when (capability) {
|
||||||
GeodeUtils.CAPABILITY_EXTENDED_INPUT -> {
|
GeodeUtils.CAPABILITY_EXTENDED_INPUT -> {
|
||||||
@@ -562,6 +657,14 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
mGLSurfaceView?.sendTimestampEvents = true
|
mGLSurfaceView?.sendTimestampEvents = true
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
GeodeUtils.CAPABILITY_RICH_INPUT -> {
|
||||||
|
mGLSurfaceView?.enableControllerEvents()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
GeodeUtils.CAPABILITY_RESIZE_CALLBACK -> {
|
||||||
|
mGLSurfaceView?.supportsResizeEvents = true
|
||||||
|
true
|
||||||
|
}
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -575,7 +678,7 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
|
|
||||||
val modListing = try {
|
val modListing = try {
|
||||||
assets.list(internalModBase)
|
assets.list(internalModBase)
|
||||||
} catch (ioe: IOException) {
|
} catch (_: IOException) {
|
||||||
emptyArray<String>()
|
emptyArray<String>()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -597,4 +700,128 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun copyGeodeResources() {
|
||||||
|
@Suppress("SENSELESS_COMPARISON")
|
||||||
|
if (BuildConfig.PREBUNDLED_GEODE == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val resourcesDirectory = LaunchUtils.getGeodeResourcesDirectory(this)
|
||||||
|
|
||||||
|
val pathBase = "geode.loader"
|
||||||
|
val versionFilename = "version"
|
||||||
|
|
||||||
|
val bundledVersion = try {
|
||||||
|
assets.open("$pathBase/$versionFilename").bufferedReader().use {
|
||||||
|
it.readText()
|
||||||
|
}.trimEnd()
|
||||||
|
} catch (_: IOException) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val versionFile = File(resourcesDirectory, versionFilename)
|
||||||
|
val resourcesVersion = if (versionFile.exists()) {
|
||||||
|
versionFile.readText().trimEnd()
|
||||||
|
} else null
|
||||||
|
|
||||||
|
if (bundledVersion == resourcesVersion) {
|
||||||
|
println("copyGeodeResources: found $resourcesVersion, expected $bundledVersion. No work needs to be done!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resourcesDirectory.exists()) {
|
||||||
|
resourcesDirectory.deleteRecursively()
|
||||||
|
}
|
||||||
|
resourcesDirectory.mkdirs()
|
||||||
|
|
||||||
|
println("copyGeodeResources: found $resourcesVersion, expected $bundledVersion. Copying assets")
|
||||||
|
|
||||||
|
val fileListing = try {
|
||||||
|
assets.list(pathBase)
|
||||||
|
} catch (_: IOException) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fileListing?.forEach { fileName ->
|
||||||
|
val resourceOutput = File(resourcesDirectory, fileName)
|
||||||
|
|
||||||
|
val resource = assets.open("$pathBase/$fileName")
|
||||||
|
DownloadUtils.copyFile(resource, resourceOutput.outputStream())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class EGLConfigChooser : GLSurfaceView.EGLConfigChooser {
|
||||||
|
// this comes from EGL14, but is unavailable on EGL10
|
||||||
|
// also EGL14 is incompatible with EGL10. so whatever
|
||||||
|
private var EGL_OPENGL_ES2_BIT: Int = 0x04
|
||||||
|
|
||||||
|
private data class ConfigValues(
|
||||||
|
val redSize: Int,
|
||||||
|
val greenSize: Int,
|
||||||
|
val blueSize: Int,
|
||||||
|
val alphaSize: Int,
|
||||||
|
val depthSize: Int,
|
||||||
|
val stencilSize: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun getAttribValue(egl: EGL10, display: EGLDisplay, config: EGLConfig, attrib: Int): Int {
|
||||||
|
val value = IntArray(1)
|
||||||
|
egl.eglGetConfigAttrib(display, config, attrib, value)
|
||||||
|
|
||||||
|
return value[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun testConfig(egl: EGL10, display: EGLDisplay, attrib: ConfigValues): EGLConfig? {
|
||||||
|
val attribList = intArrayOf(
|
||||||
|
EGL10.EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT,
|
||||||
|
EGL10.EGL_RED_SIZE, attrib.redSize,
|
||||||
|
EGL10.EGL_GREEN_SIZE, attrib.greenSize,
|
||||||
|
EGL10.EGL_BLUE_SIZE, attrib.blueSize,
|
||||||
|
EGL10.EGL_ALPHA_SIZE, attrib.alphaSize,
|
||||||
|
EGL10.EGL_DEPTH_SIZE, attrib.depthSize,
|
||||||
|
EGL10.EGL_STENCIL_SIZE, attrib.stencilSize,
|
||||||
|
EGL10.EGL_NONE
|
||||||
|
)
|
||||||
|
|
||||||
|
val configs = arrayOfNulls<EGLConfig>(1)
|
||||||
|
val numConfigs = IntArray(1)
|
||||||
|
|
||||||
|
val res = egl.eglChooseConfig(display, attribList, configs, configs.size, numConfigs)
|
||||||
|
if (res && numConfigs[0] > 0 && configs[0] != null) {
|
||||||
|
return configs[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun printConfig(egl: EGL10, display: EGLDisplay, config: EGLConfig) {
|
||||||
|
val id = getAttribValue(egl, display, config, EGL10.EGL_CONFIG_ID)
|
||||||
|
val red = getAttribValue(egl, display, config, EGL10.EGL_RED_SIZE)
|
||||||
|
val green = getAttribValue(egl, display, config, EGL10.EGL_GREEN_SIZE)
|
||||||
|
val blue = getAttribValue(egl, display, config, EGL10.EGL_BLUE_SIZE)
|
||||||
|
val alpha = getAttribValue(egl, display, config, EGL10.EGL_ALPHA_SIZE)
|
||||||
|
val depth = getAttribValue(egl, display, config, EGL10.EGL_DEPTH_SIZE)
|
||||||
|
val stencil = getAttribValue(egl, display, config, EGL10.EGL_STENCIL_SIZE)
|
||||||
|
println("EGLConfig $id: (red = $red, green = $green, blue = $blue, alpha = $alpha, depth = $depth, stencil = $stencil)")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun chooseConfig(egl: EGL10, display: EGLDisplay): EGLConfig {
|
||||||
|
val configs = listOf(
|
||||||
|
ConfigValues(8, 8, 8, 8, 16, 8),
|
||||||
|
ConfigValues(8, 8, 8, 0, 16, 8),
|
||||||
|
ConfigValues(5, 6, 5, 0, 16, 8),
|
||||||
|
ConfigValues(0, 0, 0, 0, 0, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
return configs.firstNotNullOf {
|
||||||
|
val config = testConfig(egl, display, it)
|
||||||
|
if (config != null) {
|
||||||
|
printConfig(egl, display, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.geode.launcher
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageInstaller
|
||||||
|
import android.widget.Toast
|
||||||
|
import com.geode.launcher.main.clearDownloadedApks
|
||||||
|
|
||||||
|
class InstallReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
when (val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -1)) {
|
||||||
|
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
||||||
|
val activityIntent = intent.getParcelableExtra<Intent>(Intent.EXTRA_INTENT)
|
||||||
|
|
||||||
|
if (activityIntent != null) {
|
||||||
|
context.startActivity(activityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PackageInstaller.STATUS_SUCCESS -> {
|
||||||
|
clearDownloadedApks(context)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val msg = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
|
||||||
|
println("failed to install update: $status, $msg")
|
||||||
|
|
||||||
|
val message = context.getString(R.string.launcher_self_update_failed, msg)
|
||||||
|
Toast.makeText(context, message, Toast.LENGTH_SHORT)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ object LauncherFix {
|
|||||||
|
|
||||||
external fun setOriginalDataPath(dataPath: String)
|
external fun setOriginalDataPath(dataPath: String)
|
||||||
|
|
||||||
external fun performExceptionsRenaming()
|
external fun enableExceptionsRenaming()
|
||||||
|
|
||||||
external fun enableCustomSymbolList(symbolsPath: String)
|
external fun performPatches()
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -10,7 +10,10 @@ 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 androidx.annotation.RequiresApi
|
||||||
|
import com.geode.launcher.utils.ApplicationIcon
|
||||||
|
import com.geode.launcher.utils.IconUtils
|
||||||
import com.geode.launcher.utils.LaunchUtils
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
private val DEFAULT_ROOT_PROJECTION: Array<String> = arrayOf(
|
private val DEFAULT_ROOT_PROJECTION: Array<String> = arrayOf(
|
||||||
@@ -40,7 +43,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
|
||||||
}
|
}
|
||||||
@@ -60,8 +63,17 @@ class UserDirectoryProvider : DocumentsProvider() {
|
|||||||
DocumentsContract.Root.FLAG_SUPPORTS_RECENTS or
|
DocumentsContract.Root.FLAG_SUPPORTS_RECENTS or
|
||||||
DocumentsContract.Root.FLAG_SUPPORTS_SEARCH
|
DocumentsContract.Root.FLAG_SUPPORTS_SEARCH
|
||||||
)
|
)
|
||||||
add(DocumentsContract.Root.COLUMN_TITLE, context.getString(R.string.app_name))
|
|
||||||
add(DocumentsContract.Root.COLUMN_ICON, R.drawable.geode_logo)
|
val selectedIconKey = PreferenceUtils.get(context).getString(PreferenceUtils.Key.SELECTED_ICON)
|
||||||
|
?: "geode"
|
||||||
|
val selectedIcon = ApplicationIcon.fromId(selectedIconKey)
|
||||||
|
val iconDetails = IconUtils.getIconDetails(selectedIcon)
|
||||||
|
|
||||||
|
val nameId = if (selectedIcon == ApplicationIcon.SAPPHIRE) R.string.application_icon_sapphire else R.string.app_name
|
||||||
|
val logoId = if (selectedIcon == ApplicationIcon.GEODE) R.drawable.geode_logo else iconDetails.iconId
|
||||||
|
|
||||||
|
add(DocumentsContract.Root.COLUMN_TITLE, context.getString(nameId))
|
||||||
|
add(DocumentsContract.Root.COLUMN_ICON, logoId)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package com.geode.launcher.log
|
||||||
|
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
abstract class BaseDirectoryViewModel<T>(protected val baseDirectory: File) : ViewModel() {
|
||||||
|
private val _lineState = mutableStateListOf<T>()
|
||||||
|
val lineState = _lineState
|
||||||
|
|
||||||
|
private val _isLoading = MutableStateFlow(false)
|
||||||
|
val isLoading = _isLoading.asStateFlow()
|
||||||
|
|
||||||
|
private var logJob: Job? = null
|
||||||
|
|
||||||
|
abstract fun getFileList(): List<T>
|
||||||
|
|
||||||
|
abstract fun getItemKey(item: T): Any
|
||||||
|
|
||||||
|
open fun clearAllFiles() {
|
||||||
|
logJob?.cancel()
|
||||||
|
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
if (baseDirectory.exists()) {
|
||||||
|
val files = baseDirectory.listFiles()
|
||||||
|
files?.forEach {
|
||||||
|
it.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_lineState.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadLogs() {
|
||||||
|
_isLoading.value = true
|
||||||
|
|
||||||
|
logJob?.cancel()
|
||||||
|
logJob = viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val lines = getFileList()
|
||||||
|
|
||||||
|
_lineState.clear()
|
||||||
|
_lineState.addAll(lines)
|
||||||
|
|
||||||
|
_isLoading.value = false
|
||||||
|
|
||||||
|
logJob = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeFile(filename: String) {
|
||||||
|
logJob?.cancel()
|
||||||
|
|
||||||
|
val crashPath = File(baseDirectory, filename)
|
||||||
|
if (crashPath.exists()) {
|
||||||
|
crashPath.delete()
|
||||||
|
loadLogs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadLogs()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package com.geode.launcher.log
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.lifecycle.viewmodel.initializer
|
||||||
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
data class CrashDump(
|
||||||
|
val filename: String,
|
||||||
|
val lastModified: Instant,
|
||||||
|
val fullPath: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
class CrashViewModel(crashDirectory: File) : BaseDirectoryViewModel<CrashDump>(crashDirectory) {
|
||||||
|
companion object {
|
||||||
|
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||||
|
initializer {
|
||||||
|
val application = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as Application
|
||||||
|
val crashDirectory = LaunchUtils.getCrashDirectory(application)
|
||||||
|
|
||||||
|
CrashViewModel(crashDirectory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasIndicator = false
|
||||||
|
|
||||||
|
override fun getItemKey(item: CrashDump) = item.filename
|
||||||
|
|
||||||
|
override fun getFileList(): List<CrashDump> {
|
||||||
|
val crashDirectory = baseDirectory
|
||||||
|
|
||||||
|
if (!crashDirectory.exists()) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
val children = crashDirectory.listFiles {
|
||||||
|
// ignore indicator files (including old file)
|
||||||
|
_, name -> !LaunchUtils.CRASH_INDICATOR_NAMES.contains(name)
|
||||||
|
} ?: return emptyList()
|
||||||
|
|
||||||
|
hasIndicator = LaunchUtils.CRASH_INDICATOR_NAMES.any {
|
||||||
|
File(crashDirectory, it).exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
return children
|
||||||
|
.map {
|
||||||
|
CrashDump(
|
||||||
|
filename = it.name,
|
||||||
|
lastModified = Instant.fromEpochMilliseconds(it.lastModified()),
|
||||||
|
fullPath = it.path,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.sortedByDescending { it.lastModified }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearIndicator() {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
LaunchUtils.CRASH_INDICATOR_NAMES.forEach {
|
||||||
|
val crashFile = File(baseDirectory, it)
|
||||||
|
if (crashFile.exists()) {
|
||||||
|
crashFile.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hasIndicator = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createIndicator() {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val crashFile = File(baseDirectory, LaunchUtils.CRASH_INDICATOR_NAMES.first())
|
||||||
|
crashFile.createNewFile()
|
||||||
|
|
||||||
|
hasIndicator = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clearAllFiles() {
|
||||||
|
super.clearAllFiles()
|
||||||
|
hasIndicator = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.geode.launcher.log
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewmodel.initializer
|
||||||
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
data class GeodeLog(
|
||||||
|
val filename: String,
|
||||||
|
val lastModified: Instant,
|
||||||
|
val fileSize: Long,
|
||||||
|
val fullPath: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
class GeodeLogsViewModel(crashDirectory: File) : BaseDirectoryViewModel<GeodeLog>(crashDirectory) {
|
||||||
|
companion object {
|
||||||
|
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||||
|
initializer {
|
||||||
|
val application = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as Application
|
||||||
|
val crashDirectory = LaunchUtils.getGeodeLogsDirectory(application)
|
||||||
|
|
||||||
|
GeodeLogsViewModel(crashDirectory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemKey(item: GeodeLog) = item.filename
|
||||||
|
|
||||||
|
override fun getFileList(): List<GeodeLog> {
|
||||||
|
if (!baseDirectory.exists()) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
val children = baseDirectory.listFiles()
|
||||||
|
?: return emptyList()
|
||||||
|
|
||||||
|
return children
|
||||||
|
.map {
|
||||||
|
GeodeLog(
|
||||||
|
filename = it.name,
|
||||||
|
lastModified = Instant.fromEpochMilliseconds(it.lastModified()),
|
||||||
|
fileSize = it.length(),
|
||||||
|
fullPath = it.path,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.sortedByDescending { it.lastModified }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getFileText(filename: String): List<String> {
|
||||||
|
val logFile = File(baseDirectory, filename)
|
||||||
|
if (!logFile.exists()) {
|
||||||
|
return listOf("File does not exist!")
|
||||||
|
}
|
||||||
|
|
||||||
|
return runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
logFile.readLines()
|
||||||
|
}
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
package com.geode.launcher.log
|
package com.geode.launcher.log
|
||||||
|
|
||||||
import kotlinx.datetime.Clock
|
import kotlin.time.Clock
|
||||||
import kotlinx.datetime.Instant
|
import kotlin.time.Instant
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.toJavaInstant
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import okio.Buffer
|
||||||
import okio.BufferedSource
|
import okio.BufferedSource
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import kotlin.time.ExperimentalTime
|
||||||
|
import kotlin.time.toJavaInstant
|
||||||
|
|
||||||
fun BufferedSource.readCChar(): Char {
|
fun BufferedSource.readCChar(): Char {
|
||||||
return this.readUtf8CodePoint().toChar()
|
return this.readUtf8CodePoint().toChar()
|
||||||
@@ -114,7 +116,7 @@ enum class LogPriority {
|
|||||||
/**
|
/**
|
||||||
* Represents a log entry from logcat.
|
* Represents a log entry from logcat.
|
||||||
*/
|
*/
|
||||||
data class LogLine(
|
data class LogLine @OptIn(ExperimentalTime::class) constructor(
|
||||||
val process: ProcessInformation,
|
val process: ProcessInformation,
|
||||||
val time: Instant,
|
val time: Instant,
|
||||||
val logId: Int,
|
val logId: Int,
|
||||||
@@ -133,6 +135,7 @@ data class LogLine(
|
|||||||
else -> throw IOException("LogLine::fromInputStream: unknown format for (headerSize = $size)")
|
else -> throw IOException("LogLine::fromInputStream: unknown format for (headerSize = $size)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTime::class)
|
||||||
fun fromBufferedSource(source: BufferedSource): LogLine {
|
fun fromBufferedSource(source: BufferedSource): LogLine {
|
||||||
/*
|
/*
|
||||||
// from android <liblog/include/log/log_read.h>
|
// from android <liblog/include/log/log_read.h>
|
||||||
@@ -161,7 +164,7 @@ data class LogLine(
|
|||||||
};
|
};
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* val payloadLength = */ source.readShortLe()
|
val payloadLength = source.readShortLe().toLong()
|
||||||
val headerSize = source.readShortLe().toUShort()
|
val headerSize = source.readShortLe().toUShort()
|
||||||
|
|
||||||
val entryVersion = headerSizeToVersion(headerSize)
|
val entryVersion = headerSizeToVersion(headerSize)
|
||||||
@@ -177,11 +180,17 @@ data class LogLine(
|
|||||||
val processInformation = ProcessInformation(pid, tid, uid)
|
val processInformation = ProcessInformation(pid, tid, uid)
|
||||||
val time = Instant.fromEpochSeconds(sec, nSec)
|
val time = Instant.fromEpochSeconds(sec, nSec)
|
||||||
|
|
||||||
val priorityByte = source.readByte()
|
// 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 priority = LogPriority.fromByte(priorityByte)
|
||||||
|
|
||||||
val tag = source.readCString()
|
val tag = packetBuffer.readCString()
|
||||||
val message = source.readCString()
|
val message = packetBuffer.readUtf8()
|
||||||
|
|
||||||
return LogLine(
|
return LogLine(
|
||||||
process = processInformation,
|
process = processInformation,
|
||||||
@@ -193,6 +202,7 @@ data class LogLine(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTime::class)
|
||||||
fun showException(exception: Exception) = LogLine(
|
fun showException(exception: Exception) = LogLine(
|
||||||
process = ProcessInformation(0, 0, 0),
|
process = ProcessInformation(0, 0, 0),
|
||||||
time = Clock.System.now(),
|
time = Clock.System.now(),
|
||||||
@@ -203,10 +213,14 @@ data class LogLine(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTime::class)
|
||||||
val identifier = time.toJavaInstant()
|
val identifier = time.toJavaInstant()
|
||||||
|
|
||||||
val messageTrimmed by lazy { this.message.trim() }
|
val messageTrimmed by lazy {
|
||||||
|
this.message.trim { it <= ' ' }
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTime::class)
|
||||||
val formattedTime by lazy { this.time.toLocalDateTime(TimeZone.currentSystemDefault()) }
|
val formattedTime by lazy { this.time.toLocalDateTime(TimeZone.currentSystemDefault()) }
|
||||||
val asSimpleString by lazy {
|
val asSimpleString by lazy {
|
||||||
"$formattedTime [${this.priority.toChar()}/${this.tag}]: ${this.messageTrimmed}"
|
"$formattedTime [${this.priority.toChar()}/${this.tag}]: ${this.messageTrimmed}"
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import kotlinx.coroutines.coroutineScope
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.yield
|
import kotlinx.coroutines.yield
|
||||||
|
import okio.Buffer
|
||||||
import okio.buffer
|
import okio.buffer
|
||||||
import okio.source
|
import okio.source
|
||||||
import java.io.EOFException
|
import java.io.EOFException
|
||||||
@@ -61,7 +63,9 @@ class LogViewModel: ViewModel() {
|
|||||||
else "logcat -B -d"
|
else "logcat -B -d"
|
||||||
|
|
||||||
val logProcess = try {
|
val logProcess = try {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
Runtime.getRuntime().exec(logCommand)
|
Runtime.getRuntime().exec(logCommand)
|
||||||
|
}
|
||||||
} catch (ioe: IOException) {
|
} catch (ioe: IOException) {
|
||||||
ioe.printStackTrace()
|
ioe.printStackTrace()
|
||||||
logLines += LogLine.showException(ioe)
|
logLines += LogLine.showException(ioe)
|
||||||
@@ -71,13 +75,15 @@ class LogViewModel: ViewModel() {
|
|||||||
return logLines
|
return logLines
|
||||||
}
|
}
|
||||||
|
|
||||||
val logSource = logProcess.inputStream.source().buffer()
|
// 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 {
|
try {
|
||||||
coroutineScope {
|
coroutineScope {
|
||||||
// this runs until the stream is exhausted
|
// this runs until the stream is exhausted
|
||||||
while (true) {
|
while (true) {
|
||||||
val line = LogLine.fromBufferedSource(logSource)
|
val line = LogLine.fromBufferedSource(logBuffer)
|
||||||
|
|
||||||
if (line.priority >= logLevel) {
|
if (line.priority >= logLevel) {
|
||||||
logLines += line
|
logLines += line
|
||||||
|
|||||||
@@ -3,18 +3,19 @@ package com.geode.launcher.main
|
|||||||
import android.content.ActivityNotFoundException
|
import android.content.ActivityNotFoundException
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.clickable
|
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.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.sizeIn
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.AbsoluteRoundedCornerShape
|
import androidx.compose.foundation.shape.AbsoluteRoundedCornerShape
|
||||||
import androidx.compose.material3.ElevatedCard
|
import androidx.compose.material3.ElevatedCard
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
@@ -23,12 +24,13 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.geode.launcher.R
|
import com.geode.launcher.R
|
||||||
import com.geode.launcher.utils.Constants
|
import com.geode.launcher.utils.Constants
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
|
||||||
fun onDownloadGame(context: Context) {
|
fun onDownloadGame(context: Context) {
|
||||||
val appUrl = "https://play.google.com/store/apps/details?id=${Constants.PACKAGE_NAME}"
|
val appUrl = "https://play.google.com/store/apps/details?id=${Constants.PACKAGE_NAME}"
|
||||||
try {
|
try {
|
||||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||||
data = Uri.parse(appUrl)
|
data = appUrl.toUri()
|
||||||
setPackage("com.android.vending")
|
setPackage("com.android.vending")
|
||||||
}
|
}
|
||||||
context.startActivity(intent)
|
context.startActivity(intent)
|
||||||
@@ -52,17 +54,18 @@ fun GooglePlayBadge(modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StatusIndicator(
|
fun InlineDialog(headline: @Composable () -> Unit, body: @Composable () -> Unit, modifier: Modifier = Modifier) {
|
||||||
icon: @Composable () -> Unit,
|
ElevatedCard(modifier = modifier) {
|
||||||
text: @Composable () -> Unit,
|
Column(modifier = Modifier.padding(18.dp).sizeIn(maxWidth = 512.dp)) {
|
||||||
onClick: () -> Unit,
|
Row(
|
||||||
modifier: Modifier = Modifier
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
) {
|
) {
|
||||||
ElevatedCard(modifier = modifier, onClick = onClick) {
|
headline()
|
||||||
Row(modifier = Modifier.padding(8.dp)) {
|
}
|
||||||
icon()
|
|
||||||
Spacer(Modifier.size(8.dp))
|
body()
|
||||||
text()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.geode.launcher.main
|
package com.geode.launcher.main
|
||||||
|
|
||||||
import android.content.ActivityNotFoundException
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.ClipData
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
@@ -17,10 +18,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.ClickableText
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Share
|
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -35,26 +34,35 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalUriHandler
|
import androidx.compose.ui.platform.LocalResources
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.LinkAnnotation
|
||||||
import androidx.compose.ui.text.TextStyle
|
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.font.FontFamily
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.text.withLink
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.geode.launcher.preferences.ApplicationLogsActivity
|
import com.geode.launcher.preferences.ApplicationLogsActivity
|
||||||
import com.geode.launcher.BuildConfig
|
import com.geode.launcher.BuildConfig
|
||||||
import com.geode.launcher.R
|
import com.geode.launcher.R
|
||||||
import com.geode.launcher.UserDirectoryProvider
|
import com.geode.launcher.UserDirectoryProvider
|
||||||
|
import com.geode.launcher.preferences.GeodeLogsListingActivity
|
||||||
|
import com.geode.launcher.preferences.TextViewActivity
|
||||||
import com.geode.launcher.ui.theme.Typography
|
import com.geode.launcher.ui.theme.Typography
|
||||||
import com.geode.launcher.utils.GamePackageUtils
|
import com.geode.launcher.utils.GamePackageUtils
|
||||||
import com.geode.launcher.utils.LaunchUtils
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
import com.geode.launcher.utils.PreferenceUtils
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
data class LoadFailureInfo(
|
data class LoadFailureInfo(
|
||||||
val title: LaunchUtils.LauncherError,
|
val title: LaunchUtils.LauncherError,
|
||||||
@@ -70,8 +78,20 @@ fun ErrorInfoBody(failureReason: LaunchUtils.LauncherError, modifier: Modifier =
|
|||||||
else -> stringResource(R.string.load_failed_generic_error_description)
|
else -> stringResource(R.string.load_failed_generic_error_description)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
val recommendations = when (failureReason) {
|
val recommendations = when (failureReason) {
|
||||||
LaunchUtils.LauncherError.LINKER_FAILED -> listOf(
|
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_reinstall),
|
||||||
stringResource(R.string.load_failed_recommendation_update),
|
stringResource(R.string.load_failed_recommendation_update),
|
||||||
)
|
)
|
||||||
@@ -100,6 +120,7 @@ fun ErrorInfoTitle(failureReason: LaunchUtils.LauncherError) {
|
|||||||
val message = when (failureReason) {
|
val message = when (failureReason) {
|
||||||
LaunchUtils.LauncherError.LINKER_NEEDS_64BIT,
|
LaunchUtils.LauncherError.LINKER_NEEDS_64BIT,
|
||||||
LaunchUtils.LauncherError.LINKER_NEEDS_32BIT,
|
LaunchUtils.LauncherError.LINKER_NEEDS_32BIT,
|
||||||
|
LaunchUtils.LauncherError.LINKER_FAILED_STL,
|
||||||
LaunchUtils.LauncherError.LINKER_FAILED -> stringResource(R.string.load_failed_link_error)
|
LaunchUtils.LauncherError.LINKER_FAILED -> stringResource(R.string.load_failed_link_error)
|
||||||
LaunchUtils.LauncherError.GENERIC -> stringResource(R.string.load_failed_generic_error)
|
LaunchUtils.LauncherError.GENERIC -> stringResource(R.string.load_failed_generic_error)
|
||||||
LaunchUtils.LauncherError.CRASHED -> stringResource(R.string.load_failed_crashed)
|
LaunchUtils.LauncherError.CRASHED -> stringResource(R.string.load_failed_crashed)
|
||||||
@@ -119,14 +140,28 @@ fun ErrorInfoTitle(failureReason: LaunchUtils.LauncherError) {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onShareCrash(context: Context) {
|
fun findSomethingForCrash(context: Context): File? {
|
||||||
val lastCrash = LaunchUtils.getLastCrash(context)
|
val lastCrash = LaunchUtils.getLastCrash(context)
|
||||||
|
if (lastCrash != null) {
|
||||||
|
return lastCrash
|
||||||
|
}
|
||||||
|
|
||||||
|
val logsDirectory = LaunchUtils.getGeodeLogsDirectory(context)
|
||||||
|
if (!logsDirectory.exists()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return logsDirectory.listFiles()?.maxByOrNull { it.lastModified() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onShareCrash(context: Context) {
|
||||||
|
val lastCrash = findSomethingForCrash(context)
|
||||||
if (lastCrash == null) {
|
if (lastCrash == null) {
|
||||||
Toast.makeText(context, R.string.launcher_error_export_missing, Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, R.string.launcher_error_export_missing, Toast.LENGTH_SHORT).show()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val baseDirectory = LaunchUtils.getBaseDirectory(context)
|
val baseDirectory = LaunchUtils.getBaseDirectory(context, true)
|
||||||
|
|
||||||
val providerLocation = lastCrash.toRelativeString(baseDirectory)
|
val providerLocation = lastCrash.toRelativeString(baseDirectory)
|
||||||
val documentsPath = "${UserDirectoryProvider.ROOT}${providerLocation}"
|
val documentsPath = "${UserDirectoryProvider.ROOT}${providerLocation}"
|
||||||
@@ -145,6 +180,19 @@ fun onShareCrash(context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun openLastCrash(context: Context) {
|
||||||
|
val lastCrash = findSomethingForCrash(context)
|
||||||
|
if (lastCrash == null) {
|
||||||
|
Toast.makeText(context, R.string.launcher_error_export_missing, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val launchIntent = Intent(context, TextViewActivity::class.java).run {
|
||||||
|
putExtra(TextViewActivity.EXTRA_LOG_VIEW_FILENAME, lastCrash.path)
|
||||||
|
}
|
||||||
|
context.startActivity(launchIntent)
|
||||||
|
}
|
||||||
|
|
||||||
fun onShowLogs(context: Context) {
|
fun onShowLogs(context: Context) {
|
||||||
val launchIntent = Intent(context, ApplicationLogsActivity::class.java)
|
val launchIntent = Intent(context, ApplicationLogsActivity::class.java)
|
||||||
context.startActivity(launchIntent)
|
context.startActivity(launchIntent)
|
||||||
@@ -154,6 +202,10 @@ fun onShowLogs(context: Context) {
|
|||||||
fun ErrorInfoDescription(description: String, modifier: Modifier = Modifier) {
|
fun ErrorInfoDescription(description: String, modifier: Modifier = Modifier) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val currentRelease = remember {
|
val currentRelease = remember {
|
||||||
|
@Suppress("SENSELESS_COMPARISON")
|
||||||
|
if (BuildConfig.PREBUNDLED_GEODE != null)
|
||||||
|
"${BuildConfig.PREBUNDLED_GEODE}*"
|
||||||
|
else
|
||||||
PreferenceUtils.get(context).getString(PreferenceUtils.Key.CURRENT_VERSION_TAG)
|
PreferenceUtils.get(context).getString(PreferenceUtils.Key.CURRENT_VERSION_TAG)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,6 +213,7 @@ fun ErrorInfoDescription(description: String, modifier: Modifier = Modifier) {
|
|||||||
GamePackageUtils.getUnifiedVersionName(context.packageManager)
|
GamePackageUtils.getUnifiedVersionName(context.packageManager)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SelectionContainer {
|
||||||
Column(modifier = modifier) {
|
Column(modifier = modifier) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.launcher_error_details),
|
stringResource(R.string.launcher_error_details),
|
||||||
@@ -182,19 +235,25 @@ fun ErrorInfoDescription(description: String, modifier: Modifier = Modifier) {
|
|||||||
Build.MODEL,
|
Build.MODEL,
|
||||||
Build.VERSION.RELEASE,
|
Build.VERSION.RELEASE,
|
||||||
currentRelease ?: "unknown",
|
currentRelease ?: "unknown",
|
||||||
gameVersion
|
gameVersion,
|
||||||
|
LaunchUtils.applicationArchitecture,
|
||||||
|
BuildConfig.VERSION_NAME
|
||||||
),
|
),
|
||||||
style = Typography.labelMedium
|
style = Typography.labelMedium
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
fun ErrorInfoActions(extraDetails: String?, modifier: Modifier = Modifier) {
|
fun ErrorInfoActions(extraDetails: String?, modifier: Modifier = Modifier) {
|
||||||
val clipboardManager = LocalClipboardManager.current
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
val clipboard = LocalClipboard.current
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val resources = LocalResources.current
|
||||||
|
|
||||||
FlowRow(
|
FlowRow(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
horizontalArrangement = Arrangement.spacedBy(
|
horizontalArrangement = Arrangement.spacedBy(
|
||||||
@@ -212,7 +271,15 @@ fun ErrorInfoActions(extraDetails: String?, modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
FilledTonalButton(onClick = {
|
FilledTonalButton(onClick = {
|
||||||
clipboardManager.setText(AnnotatedString(extraDetails))
|
val clipboardLabel = resources.getString(R.string.application_log_copy_label)
|
||||||
|
|
||||||
|
coroutineScope.launch {
|
||||||
|
clipboard.setClipEntry(
|
||||||
|
ClipEntry(ClipData.newPlainText(
|
||||||
|
clipboardLabel, extraDetails
|
||||||
|
))
|
||||||
|
)
|
||||||
|
}
|
||||||
}) {
|
}) {
|
||||||
Icon(
|
Icon(
|
||||||
painterResource(R.drawable.icon_content_copy),
|
painterResource(R.drawable.icon_content_copy),
|
||||||
@@ -222,16 +289,34 @@ fun ErrorInfoActions(extraDetails: String?, modifier: Modifier = Modifier) {
|
|||||||
Text(stringResource(R.string.launcher_error_copy))
|
Text(stringResource(R.string.launcher_error_copy))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
val crashFile = remember { findSomethingForCrash(context) }
|
||||||
|
val readableCrash = remember { crashFile?.extension == "log" }
|
||||||
|
|
||||||
|
if (crashFile != null) {
|
||||||
Button(onClick = { onShareCrash(context) }) {
|
Button(onClick = { onShareCrash(context) }) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.Share,
|
painterResource(R.drawable.icon_share),
|
||||||
contentDescription = null
|
contentDescription = null
|
||||||
)
|
)
|
||||||
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||||
Text(stringResource(R.string.launcher_error_export_crash))
|
Text(stringResource(R.string.launcher_error_export_crash))
|
||||||
}
|
}
|
||||||
|
|
||||||
FilledTonalButton(onClick = { onShowLogs(context) }) {
|
if (readableCrash) {
|
||||||
|
FilledTonalButton(onClick = { openLastCrash(context) }) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_description),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||||
|
Text(stringResource(R.string.launcher_error_view_details))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Button(onClick = {
|
||||||
|
val launchIntent = Intent(context, GeodeLogsListingActivity::class.java)
|
||||||
|
context.startActivity(launchIntent)
|
||||||
|
}) {
|
||||||
Icon(
|
Icon(
|
||||||
painterResource(R.drawable.icon_description),
|
painterResource(R.drawable.icon_description),
|
||||||
contentDescription = null
|
contentDescription = null
|
||||||
@@ -242,18 +327,15 @@ fun ErrorInfoActions(extraDetails: String?, modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DownloadRecommendation(needsUniversal: Boolean, modifier: Modifier = Modifier) {
|
fun DownloadRecommendation(needsUniversal: Boolean, modifier: Modifier = Modifier) {
|
||||||
val uriHandler = LocalUriHandler.current
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val version = BuildConfig.VERSION_NAME
|
val version = BuildConfig.VERSION_NAME
|
||||||
|
|
||||||
val showDownload = remember {
|
val showDownload = remember { GamePackageUtils.showDownloadBadge(context.packageManager) }
|
||||||
!GamePackageUtils.isGameInstalled(context.packageManager) ||
|
|
||||||
!GamePackageUtils.identifyGameLegitimacy(context.packageManager)
|
|
||||||
}
|
|
||||||
|
|
||||||
val downloadBase = "https://github.com/geode-sdk/android-launcher/releases/download/$version"
|
val downloadBase = "https://github.com/geode-sdk/android-launcher/releases/download/$version"
|
||||||
val legacyDownloadUrl = "$downloadBase/geode-launcher-v$version-android32.apk"
|
val legacyDownloadUrl = "$downloadBase/geode-launcher-v$version-android32.apk"
|
||||||
@@ -289,17 +371,21 @@ fun DownloadRecommendation(needsUniversal: Boolean, modifier: Modifier = Modifie
|
|||||||
|
|
||||||
Text("\u2022\u00A0\u00A0$recommendationText")
|
Text("\u2022\u00A0\u00A0$recommendationText")
|
||||||
|
|
||||||
ClickableText(
|
Text(
|
||||||
text = AnnotatedString(downloadText),
|
buildAnnotatedString {
|
||||||
onClick = {
|
withLink(LinkAnnotation.Url(
|
||||||
uriHandler.openUri(downloadUrl)
|
downloadUrl,
|
||||||
},
|
TextLinkStyles(
|
||||||
|
style = SpanStyle(
|
||||||
style = TextStyle.Default.copy(
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
textDecoration = TextDecoration.Underline
|
textDecoration = TextDecoration.Underline
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
)) {
|
||||||
|
append(downloadText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,21 +4,12 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.interaction.PressInteraction
|
import androidx.compose.foundation.interaction.PressInteraction
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
|
||||||
import androidx.compose.material.icons.filled.Settings
|
|
||||||
import androidx.compose.material.icons.filled.Warning
|
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
@@ -28,27 +19,24 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.geode.launcher.GeometryDashActivity
|
import com.geode.launcher.GeometryDashActivity
|
||||||
import com.geode.launcher.R
|
import com.geode.launcher.R
|
||||||
import com.geode.launcher.preferences.SettingsActivity
|
import com.geode.launcher.preferences.SettingsActivity
|
||||||
import com.geode.launcher.ui.theme.Typography
|
|
||||||
import com.geode.launcher.utils.Constants
|
import com.geode.launcher.utils.Constants
|
||||||
import com.geode.launcher.utils.GamePackageUtils
|
import com.geode.launcher.utils.GamePackageUtils
|
||||||
import com.geode.launcher.utils.GeodeUtils
|
import com.geode.launcher.utils.GeodeUtils
|
||||||
import com.geode.launcher.utils.PreferenceUtils
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
import com.geode.launcher.utils.useCountdownTimer
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -71,7 +59,7 @@ fun UpdateWarning(inSafeMode: Boolean = false, onDismiss: () -> Unit) {
|
|||||||
AlertDialog(
|
AlertDialog(
|
||||||
icon = {
|
icon = {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.Warning,
|
painterResource(R.drawable.icon_warning),
|
||||||
contentDescription = stringResource(R.string.launcher_warning_icon_alt)
|
contentDescription = stringResource(R.string.launcher_warning_icon_alt)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -116,11 +104,11 @@ fun SafeModeDialog(onDismiss: () -> Unit, onLaunch: () -> Unit) {
|
|||||||
AlertDialog(
|
AlertDialog(
|
||||||
icon = {
|
icon = {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.Warning,
|
painterResource(R.drawable.icon_shield_alert),
|
||||||
contentDescription = stringResource(R.string.launcher_warning_icon_alt)
|
contentDescription = null
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
title = { Text(stringResource(R.string.safe_mode_enable_title)) },
|
title = { Text(stringResource(R.string.preference_launch_safe_mode_confirm_title)) },
|
||||||
text = { Text(stringResource(R.string.safe_mode_enable_description)) },
|
text = { Text(stringResource(R.string.safe_mode_enable_description)) },
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = onDismiss) {
|
TextButton(onClick = onDismiss) {
|
||||||
@@ -155,7 +143,7 @@ fun onSettings(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LongPressButton(onClick: () -> Unit, onLongPress: () -> Unit, enabled: Boolean = true, modifier: Modifier = Modifier, content: @Composable RowScope.() -> Unit) {
|
fun LongPressButton(onClick: () -> Unit, onLongPress: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, content: @Composable RowScope.() -> Unit) {
|
||||||
// compose apis don't provide a good way of adding long press to a button
|
// compose apis don't provide a good way of adding long press to a button
|
||||||
var isLongPress by remember { mutableStateOf(false) }
|
var isLongPress by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -167,11 +155,11 @@ fun LongPressButton(onClick: () -> Unit, onLongPress: () -> Unit, enabled: Boole
|
|||||||
interactionSource.interactions.collectLatest { interaction ->
|
interactionSource.interactions.collectLatest { interaction ->
|
||||||
when (interaction) {
|
when (interaction) {
|
||||||
is PressInteraction.Press -> {
|
is PressInteraction.Press -> {
|
||||||
delay(viewConfiguration.longPressTimeoutMillis)
|
delay(viewConfiguration.longPressTimeoutMillis.milliseconds)
|
||||||
|
|
||||||
// perform a second delay to make the action more obvious
|
// perform a second delay to make the action more obvious
|
||||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
delay(viewConfiguration.longPressTimeoutMillis)
|
delay(viewConfiguration.longPressTimeoutMillis.milliseconds)
|
||||||
|
|
||||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
|
||||||
@@ -198,97 +186,27 @@ fun LongPressButton(onClick: () -> Unit, onLongPress: () -> Unit, enabled: Boole
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PlayButton(
|
fun UnsupportedVersionWarning(modifier: Modifier = Modifier) {
|
||||||
stopAutomaticLaunch: Boolean,
|
val gameVersion = GamePackageUtils.getSupportedUnifiedVersion()
|
||||||
blockLaunch: Boolean,
|
|
||||||
onPlayGame: (Boolean) -> Unit
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
var showSafeModeDialog by remember { mutableStateOf(false) }
|
InlineDialog(
|
||||||
var launchInSafeMode by remember { mutableStateOf(false) }
|
headline = {
|
||||||
|
Icon(
|
||||||
val shouldAutomaticallyLaunch by PreferenceUtils.useBooleanPreference(
|
painterResource(R.drawable.icon_warning),
|
||||||
preferenceKey = PreferenceUtils.Key.LOAD_AUTOMATICALLY
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(28.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
if (shouldAutomaticallyLaunch && !stopAutomaticLaunch && !showSafeModeDialog) {
|
|
||||||
val countdownTimer = useCountdownTimer(
|
|
||||||
time = 3000,
|
|
||||||
onCountdownFinish = { onPlayGame(launchInSafeMode) }
|
|
||||||
)
|
|
||||||
|
|
||||||
if (countdownTimer != 0L) {
|
|
||||||
Text(
|
Text(
|
||||||
pluralStringResource(
|
stringResource(R.string.launcher_unsupported_version_title),
|
||||||
R.plurals.automatically_load_countdown,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
countdownTimer.toInt(),
|
|
||||||
countdownTimer
|
|
||||||
),
|
|
||||||
style = Typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
)
|
)
|
||||||
Spacer(Modifier.size(12.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Row {
|
|
||||||
LongPressButton(
|
|
||||||
enabled = !blockLaunch,
|
|
||||||
onLongPress = {
|
|
||||||
showSafeModeDialog = true
|
|
||||||
},
|
},
|
||||||
onClick = {
|
body = {
|
||||||
onPlayGame(launchInSafeMode)
|
Text(
|
||||||
}
|
stringResource(R.string.launcher_game_update_required, gameVersion),
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Filled.PlayArrow,
|
|
||||||
contentDescription = context.getString(R.string.launcher_launch_icon_alt)
|
|
||||||
)
|
)
|
||||||
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
|
||||||
Text(context.getString(R.string.launcher_launch))
|
|
||||||
}
|
|
||||||
Spacer(Modifier.size(2.dp))
|
|
||||||
IconButton(onClick = { onSettings(context) }) {
|
|
||||||
Icon(
|
|
||||||
Icons.Filled.Settings,
|
|
||||||
contentDescription = context.getString(R.string.launcher_settings_icon_alt)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showSafeModeDialog) {
|
|
||||||
SafeModeDialog(
|
|
||||||
onDismiss = {
|
|
||||||
launchInSafeMode = false
|
|
||||||
showSafeModeDialog = false
|
|
||||||
},
|
},
|
||||||
onLaunch = {
|
modifier = modifier.padding(8.dp)
|
||||||
launchInSafeMode = true
|
|
||||||
|
|
||||||
onPlayGame(true)
|
|
||||||
|
|
||||||
showSafeModeDialog = false
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun LaunchBlockedLabel(text: String) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
val showDownload = remember {
|
|
||||||
GamePackageUtils.isGameInstalled(context.packageManager) &&
|
|
||||||
!GamePackageUtils.identifyGameLegitimacy(context.packageManager)
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
|
||||||
Text(text, textAlign = TextAlign.Center, modifier = Modifier.padding(12.dp))
|
|
||||||
|
|
||||||
if (showDownload) {
|
|
||||||
GooglePlayBadge()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,11 +11,7 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.IntrinsicSize
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
import androidx.compose.foundation.layout.displayCutoutPadding
|
import androidx.compose.foundation.layout.displayCutoutPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Info
|
|
||||||
import androidx.compose.material.icons.filled.Warning
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.ListItem
|
import androidx.compose.material3.ListItem
|
||||||
import androidx.compose.material3.LocalContentColor
|
import androidx.compose.material3.LocalContentColor
|
||||||
@@ -26,9 +22,10 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -37,20 +34,28 @@ import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
|||||||
import com.geode.launcher.ui.theme.LocalTheme
|
import com.geode.launcher.ui.theme.LocalTheme
|
||||||
import com.geode.launcher.ui.theme.Theme
|
import com.geode.launcher.ui.theme.Theme
|
||||||
import com.geode.launcher.updater.ReleaseManager
|
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 com.geode.launcher.utils.PreferenceUtils
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
enum class LaunchNotificationType {
|
enum class LaunchNotificationType {
|
||||||
GEODE_UPDATED, LAUNCHER_UPDATE_AVAILABLE, UPDATE_FAILED;
|
GEODE_UPDATED, LAUNCHER_UPDATE_AVAILABLE, UPDATE_FAILED, UNSUPPORTED_VERSION, FAILED_LOAD;
|
||||||
}
|
}
|
||||||
|
|
||||||
fun determineDisplayedCards(context: Context): List<LaunchNotificationType> {
|
fun determineDisplayedCards(context: Context, geodeFailed: Boolean = false): List<LaunchNotificationType> {
|
||||||
val cards = mutableListOf<LaunchNotificationType>()
|
val cards = mutableListOf<LaunchNotificationType>()
|
||||||
|
|
||||||
val releaseManager = ReleaseManager.get(context)
|
if (GamePackageUtils.getGameVersionCode(context.packageManager) < Constants.SUPPORTED_VERSION_CODE_MIN_WARNING) {
|
||||||
|
cards.add(LaunchNotificationType.UNSUPPORTED_VERSION)
|
||||||
|
}
|
||||||
|
|
||||||
val availableUpdate = releaseManager.availableLauncherUpdate.value
|
val releaseManager = ReleaseManager.get(context)
|
||||||
if (availableUpdate != null) {
|
val availableUpdate = releaseManager.availableLauncherUpdateTag.value
|
||||||
|
if (availableUpdate != null && !releaseManager.dismissedLauncherUpdate) {
|
||||||
cards.add(LaunchNotificationType.LAUNCHER_UPDATE_AVAILABLE)
|
cards.add(LaunchNotificationType.LAUNCHER_UPDATE_AVAILABLE)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,54 +69,68 @@ fun determineDisplayedCards(context: Context): List<LaunchNotificationType> {
|
|||||||
cards.add(LaunchNotificationType.UPDATE_FAILED)
|
cards.add(LaunchNotificationType.UPDATE_FAILED)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (geodeFailed) {
|
||||||
|
cards.add(LaunchNotificationType.FAILED_LOAD)
|
||||||
|
}
|
||||||
|
|
||||||
return cards
|
return cards
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NotificationCardFromType(type: LaunchNotificationType) {
|
fun NotificationCardFromType(type: LaunchNotificationType) {
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
when (type) {
|
when (type) {
|
||||||
LaunchNotificationType.LAUNCHER_UPDATE_AVAILABLE -> {
|
LaunchNotificationType.LAUNCHER_UPDATE_AVAILABLE -> {
|
||||||
|
var showInfoDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
if (showInfoDialog) {
|
||||||
|
LauncherUpdateInformation {
|
||||||
|
showInfoDialog = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
AnimatedNotificationCard(
|
AnimatedNotificationCard(
|
||||||
displayLength = 5000L,
|
displayLength = 5.seconds,
|
||||||
onClick = {
|
onClick = {
|
||||||
installLauncherUpdate(context)
|
showInfoDialog = true
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
LauncherUpdateContent()
|
LauncherUpdateContent()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LaunchNotificationType.GEODE_UPDATED -> {
|
|
||||||
AnimatedNotificationCard {
|
|
||||||
UpdateNotificationContent()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LaunchNotificationType.UPDATE_FAILED -> {
|
LaunchNotificationType.UPDATE_FAILED -> {
|
||||||
AnimatedNotificationCard {
|
AnimatedNotificationCard {
|
||||||
UpdateFailedContent()
|
UpdateFailedContent()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
LaunchNotificationType.UNSUPPORTED_VERSION -> {
|
||||||
|
AnimatedNotificationCard {
|
||||||
|
OutdatedVersionContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchNotificationType.FAILED_LOAD -> {
|
||||||
|
AnimatedNotificationCard {
|
||||||
|
ForwardCompatibilityContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LaunchNotification() {
|
fun LaunchNotification(cards: List<LaunchNotificationType>) {
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
|
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
|
||||||
val theme = Theme.fromInt(themeOption)
|
val theme = Theme.fromInt(themeOption)
|
||||||
|
|
||||||
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
|
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
|
||||||
|
|
||||||
val cards = determineDisplayedCards(context)
|
|
||||||
|
|
||||||
CompositionLocalProvider(LocalTheme provides theme) {
|
CompositionLocalProvider(LocalTheme provides theme) {
|
||||||
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption) {
|
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption) {
|
||||||
// surface is not in use, so this is unfortunately not provided
|
// surface is not in use, so this is unfortunately not provided
|
||||||
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
|
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.displayCutoutPadding()
|
modifier = if (GeodeUtils.handleSafeArea)
|
||||||
|
Modifier.displayCutoutPadding()
|
||||||
|
else Modifier
|
||||||
) {
|
) {
|
||||||
cards.forEach {
|
cards.forEach {
|
||||||
NotificationCardFromType(it)
|
NotificationCardFromType(it)
|
||||||
@@ -123,15 +142,15 @@ fun LaunchNotification() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AnimatedNotificationCard(modifier: Modifier = Modifier, visibilityDelay: Long = 0L, displayLength: Long = 3000L, onClick: (() -> Unit)? = null, contents: @Composable () -> Unit) {
|
fun AnimatedNotificationCard(modifier: Modifier = Modifier, visibilityDelay: Duration = Duration.ZERO, displayLength: Duration = 3.seconds, onClick: (() -> Unit)? = null, contents: @Composable () -> Unit) {
|
||||||
val state = remember {
|
val state = remember {
|
||||||
MutableTransitionState(false).apply {
|
MutableTransitionState(false).apply {
|
||||||
targetState = visibilityDelay <= 0
|
targetState = visibilityDelay <= Duration.ZERO
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(true) {
|
LaunchedEffect(true) {
|
||||||
if (visibilityDelay > 0) {
|
if (visibilityDelay > Duration.ZERO) {
|
||||||
delay(visibilityDelay)
|
delay(visibilityDelay)
|
||||||
state.targetState = true
|
state.targetState = true
|
||||||
}
|
}
|
||||||
@@ -183,7 +202,7 @@ fun LauncherUpdateContent(modifier: Modifier = Modifier) {
|
|||||||
},
|
},
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.Info,
|
painterResource(R.drawable.icon_info),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -191,6 +210,7 @@ fun LauncherUpdateContent(modifier: Modifier = Modifier) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
@Composable
|
@Composable
|
||||||
fun UpdateNotificationContent(modifier: Modifier = Modifier) {
|
fun UpdateNotificationContent(modifier: Modifier = Modifier) {
|
||||||
ListItem(
|
ListItem(
|
||||||
@@ -207,6 +227,26 @@ fun UpdateNotificationContent(modifier: Modifier = Modifier) {
|
|||||||
modifier = modifier.width(IntrinsicSize.Max)
|
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
|
@Composable
|
||||||
fun UpdateFailedContent(modifier: Modifier = Modifier) {
|
fun UpdateFailedContent(modifier: Modifier = Modifier) {
|
||||||
@@ -216,10 +256,29 @@ fun UpdateFailedContent(modifier: Modifier = Modifier) {
|
|||||||
},
|
},
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.Warning,
|
painterResource(R.drawable.icon_warning),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
modifier = modifier.width(IntrinsicSize.Max)
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import androidx.lifecycle.ViewModelProvider
|
|||||||
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.BuildConfig
|
||||||
import com.geode.launcher.updater.ReleaseManager
|
import com.geode.launcher.updater.ReleaseManager
|
||||||
import com.geode.launcher.utils.Constants
|
import com.geode.launcher.utils.Constants
|
||||||
import com.geode.launcher.utils.GamePackageUtils
|
import com.geode.launcher.utils.GamePackageUtils
|
||||||
@@ -18,7 +19,11 @@ import kotlinx.coroutines.flow.map
|
|||||||
import kotlinx.coroutines.flow.takeWhile
|
import kotlinx.coroutines.flow.takeWhile
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
private const val COOLDOWN_LENGTH_MS = 1000L
|
data class LaunchArguments(
|
||||||
|
val autoSafeMode: Boolean,
|
||||||
|
val forcePause: Boolean,
|
||||||
|
val forceLaunch: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
class LaunchViewModel(private val application: Application): ViewModel() {
|
class LaunchViewModel(private val application: Application): ViewModel() {
|
||||||
companion object {
|
companion object {
|
||||||
@@ -37,7 +42,13 @@ class LaunchViewModel(private val application: Application): ViewModel() {
|
|||||||
private var readyTimer: CountDownTimer? = null
|
private var readyTimer: CountDownTimer? = null
|
||||||
|
|
||||||
private fun initReadyTimer() {
|
private fun initReadyTimer() {
|
||||||
readyTimer = object : CountDownTimer(COOLDOWN_LENGTH_MS, 1000) {
|
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) {
|
override fun onTick(millisUntilFinished: Long) {
|
||||||
// no tick necessary
|
// no tick necessary
|
||||||
}
|
}
|
||||||
@@ -55,37 +66,63 @@ class LaunchViewModel(private val application: Application): ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum class LaunchCancelReason {
|
enum class LaunchCancelReason {
|
||||||
MANUAL, AUTOMATIC, LAST_LAUNCH_CRASHED, GEODE_NOT_FOUND, GAME_MISSING, GAME_OUTDATED;
|
MANUAL, AUTOMATIC, LAST_LAUNCH_CRASHED, LAST_LAUNCH_CRASHED_AUTOMATIC,
|
||||||
|
GEODE_NOT_FOUND, GAME_MISSING, GAME_OUTDATED;
|
||||||
|
|
||||||
fun allowsRetry() = when (this) {
|
fun allowsRetry() = when (this) {
|
||||||
MANUAL,
|
MANUAL,
|
||||||
AUTOMATIC,
|
AUTOMATIC,
|
||||||
LAST_LAUNCH_CRASHED,
|
LAST_LAUNCH_CRASHED,
|
||||||
|
LAST_LAUNCH_CRASHED_AUTOMATIC,
|
||||||
GEODE_NOT_FOUND -> true
|
GEODE_NOT_FOUND -> true
|
||||||
GAME_MISSING,
|
GAME_MISSING,
|
||||||
GAME_OUTDATED -> false
|
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 {
|
sealed class LaunchUIState {
|
||||||
data object Initial : LaunchUIState()
|
data object Initial : LaunchUIState()
|
||||||
data object UpdateCheck : LaunchUIState()
|
data object UpdateCheck : LaunchUIState()
|
||||||
data class Updating(val downloaded: Long, val outOf: Long) : LaunchUIState()
|
data class Updating(val downloaded: Long, val outOf: Long?) : LaunchUIState()
|
||||||
data class Cancelled(val reason: LaunchCancelReason, val inProgress: Boolean = false) : LaunchUIState()
|
data class Cancelled(val reason: LaunchCancelReason, val inProgress: Boolean = false) : LaunchUIState()
|
||||||
data object Working : LaunchUIState()
|
data object Working : LaunchUIState()
|
||||||
data object Ready : 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)
|
private val _uiState = MutableStateFlow<LaunchUIState>(LaunchUIState.Initial)
|
||||||
val uiState = _uiState.asStateFlow()
|
val uiState = _uiState.asStateFlow()
|
||||||
|
|
||||||
val nextLauncherUpdate = ReleaseManager.get(application).availableLauncherUpdate
|
val nextLauncherUpdate = ReleaseManager.get(application).availableLauncherUpdateTag
|
||||||
|
|
||||||
var loadFailure: LoadFailureInfo? = null
|
var loadFailure: LoadFailureInfo? = null
|
||||||
|
var launchArguments: LaunchArguments? = null
|
||||||
|
|
||||||
private var hasCheckedForUpdates = false
|
private var hasCheckedForUpdates = false
|
||||||
private var isCancelling = false
|
private var isCancelling = false
|
||||||
private var hasManuallyStarted = false
|
private var hasManuallyStarted = false
|
||||||
|
|
||||||
|
private var restoreUpdateState: LaunchUIState? = null
|
||||||
|
|
||||||
private suspend fun determineUpdateStatus() {
|
private suspend fun determineUpdateStatus() {
|
||||||
ReleaseManager.get(application).uiState.takeWhile {
|
ReleaseManager.get(application).uiState.takeWhile {
|
||||||
it is ReleaseManager.ReleaseManagerState.InUpdateCheck || it is ReleaseManager.ReleaseManagerState.InDownload
|
it is ReleaseManager.ReleaseManagerState.InUpdateCheck || it is ReleaseManager.ReleaseManagerState.InDownload
|
||||||
@@ -100,12 +137,19 @@ class LaunchViewModel(private val application: Application): ViewModel() {
|
|||||||
|
|
||||||
// flow terminates once update check is finished
|
// flow terminates once update check is finished
|
||||||
hasCheckedForUpdates = true
|
hasCheckedForUpdates = true
|
||||||
|
|
||||||
|
val currentState = restoreUpdateState
|
||||||
|
if (currentState != null) {
|
||||||
|
_uiState.emit(currentState)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
preReadyCheck()
|
preReadyCheck()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun currentCrashInfo(): LoadFailureInfo? {
|
fun currentCrashInfo(): LoadFailureInfo? {
|
||||||
val currentState = _uiState.value
|
val currentState = _uiState.value
|
||||||
if (currentState is LaunchUIState.Cancelled && currentState.reason == LaunchCancelReason.LAST_LAUNCH_CRASHED) {
|
if (currentState is LaunchUIState.Cancelled && currentState.reason.isCrash()) {
|
||||||
return loadFailure
|
return loadFailure
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,12 +158,12 @@ class LaunchViewModel(private val application: Application): ViewModel() {
|
|||||||
|
|
||||||
fun clearCrashInfo() {
|
fun clearCrashInfo() {
|
||||||
val currentState = _uiState.value
|
val currentState = _uiState.value
|
||||||
if (currentState is LaunchUIState.Cancelled && currentState.reason == LaunchCancelReason.LAST_LAUNCH_CRASHED) {
|
if (currentState is LaunchUIState.Cancelled && currentState.reason.isCrash()) {
|
||||||
loadFailure = null
|
loadFailure = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun preReadyCheck() {
|
private fun preReadyCheck() {
|
||||||
if (isCancelling) {
|
if (isCancelling) {
|
||||||
// don't let the game start if cancelled
|
// don't let the game start if cancelled
|
||||||
return
|
return
|
||||||
@@ -128,7 +172,8 @@ class LaunchViewModel(private val application: Application): ViewModel() {
|
|||||||
val packageManager = application.packageManager
|
val packageManager = application.packageManager
|
||||||
|
|
||||||
if (!GamePackageUtils.isGameInstalled(packageManager)) {
|
if (!GamePackageUtils.isGameInstalled(packageManager)) {
|
||||||
return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_MISSING))
|
_uiState.tryEmit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_MISSING))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ReleaseManager.get(application).isInUpdate) {
|
if (ReleaseManager.get(application).isInUpdate) {
|
||||||
@@ -138,32 +183,51 @@ class LaunchViewModel(private val application: Application): ViewModel() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loadFailure != null) {
|
// play store build only comes with the one build of geode
|
||||||
// last launch crashed, so cancel
|
val minVersionCode = if (BuildConfig.GOOGLE_PLAY_BUILD) Constants.SUPPORTED_VERSION_CODE else Constants.SUPPORTED_VERSION_CODE_MIN
|
||||||
return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.LAST_LAUNCH_CRASHED))
|
if (GamePackageUtils.getGameVersionCode(packageManager) < minVersionCode) {
|
||||||
}
|
_uiState.tryEmit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_OUTDATED))
|
||||||
|
return
|
||||||
if (GamePackageUtils.getGameVersionCode(packageManager) < Constants.SUPPORTED_VERSION_CODE_MIN) {
|
|
||||||
return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_OUTDATED))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!LaunchUtils.isGeodeInstalled(application)) {
|
if (!LaunchUtils.isGeodeInstalled(application)) {
|
||||||
return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GEODE_NOT_FOUND))
|
_uiState.tryEmit(LaunchUIState.Cancelled(LaunchCancelReason.GEODE_NOT_FOUND))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// if forcing immediate launch, then act as if it's manually started (skips timers)
|
||||||
|
val forceImmediate = launchArguments?.forceLaunch == true
|
||||||
|
|
||||||
|
// last launch crashed, so cancel
|
||||||
|
if (!forceImmediate && loadFailure != null) {
|
||||||
|
// show a different message if this is the first thing users see
|
||||||
|
val cancelType = if (_uiState.value == LaunchUIState.Initial) {
|
||||||
|
LaunchCancelReason.LAST_LAUNCH_CRASHED_AUTOMATIC
|
||||||
|
} else LaunchCancelReason.LAST_LAUNCH_CRASHED
|
||||||
|
|
||||||
|
_uiState.tryEmit(LaunchUIState.Cancelled(cancelType))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (forceImmediate) {
|
||||||
|
hasManuallyStarted = true
|
||||||
}
|
}
|
||||||
|
|
||||||
val loadAutomatically = PreferenceUtils.get(application).getBoolean(PreferenceUtils.Key.LOAD_AUTOMATICALLY)
|
val loadAutomatically = PreferenceUtils.get(application).getBoolean(PreferenceUtils.Key.LOAD_AUTOMATICALLY)
|
||||||
if (!loadAutomatically && !hasManuallyStarted) {
|
val forcePause = launchArguments?.forcePause == true
|
||||||
|
if (!hasManuallyStarted && (forcePause || !loadAutomatically)) {
|
||||||
return cancelLaunch(true)
|
return cancelLaunch(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!readyTimerPassed) {
|
if (!readyTimerPassed && !hasManuallyStarted) {
|
||||||
return _uiState.emit(LaunchUIState.Working)
|
_uiState.tryEmit(LaunchUIState.Working)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_uiState.emit(LaunchUIState.Ready)
|
_uiState.tryEmit(LaunchUIState.Ready)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun beginLaunchFlow(isRestart: Boolean = false) {
|
fun beginLaunchFlow(isRestart: Boolean = false) {
|
||||||
if (isRestart) {
|
if (isRestart) {
|
||||||
hasManuallyStarted = true
|
hasManuallyStarted = true
|
||||||
}
|
}
|
||||||
@@ -179,23 +243,50 @@ class LaunchViewModel(private val application: Application): ViewModel() {
|
|||||||
isCancelling = false
|
isCancelling = false
|
||||||
|
|
||||||
if (!GamePackageUtils.isGameInstalled(application.packageManager)) {
|
if (!GamePackageUtils.isGameInstalled(application.packageManager)) {
|
||||||
return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_MISSING))
|
_uiState.tryEmit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_MISSING))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (readyTimer == null) {
|
if (readyTimer == null) {
|
||||||
initReadyTimer()
|
initReadyTimer()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!BuildConfig.GOOGLE_PLAY_BUILD) {
|
||||||
|
val releaseManager = ReleaseManager.get(application)
|
||||||
|
|
||||||
val hasGeode = LaunchUtils.isGeodeInstalled(application)
|
val hasGeode = LaunchUtils.isGeodeInstalled(application)
|
||||||
val shouldUpdate = PreferenceUtils.get(application).getBoolean(PreferenceUtils.Key.UPDATE_AUTOMATICALLY)
|
val shouldUpdate = PreferenceUtils.get(application).getBoolean(PreferenceUtils.Key.UPDATE_AUTOMATICALLY)
|
||||||
|
|
||||||
if ((shouldUpdate && !hasCheckedForUpdates) || !hasGeode) {
|
if ((shouldUpdate && !hasCheckedForUpdates) || !hasGeode) {
|
||||||
ReleaseManager.get(application).checkForUpdates(false)
|
releaseManager.checkForUpdates(false)
|
||||||
|
} else {
|
||||||
|
releaseManager.afterUseCachedUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRestart) {
|
||||||
|
releaseManager.dismissedLauncherUpdate = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
preReadyCheck()
|
preReadyCheck()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun cancelLaunch(isAutomatic: Boolean = false) {
|
fun retryUpdate() {
|
||||||
|
if (BuildConfig.GOOGLE_PLAY_BUILD) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ReleaseManager.get(application).checkForUpdates(true)
|
||||||
|
if (ReleaseManager.get(application).isInUpdate) {
|
||||||
|
restoreUpdateState = _uiState.value
|
||||||
|
viewModelScope.launch {
|
||||||
|
determineUpdateStatus()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelLaunch(isAutomatic: Boolean = false) {
|
||||||
// no need to double cancel
|
// no need to double cancel
|
||||||
if (_uiState.value is LaunchUIState.Cancelled) {
|
if (_uiState.value is LaunchUIState.Cancelled) {
|
||||||
return
|
return
|
||||||
@@ -204,14 +295,17 @@ class LaunchViewModel(private val application: Application): ViewModel() {
|
|||||||
val reason = if (isAutomatic) LaunchCancelReason.AUTOMATIC else LaunchCancelReason.MANUAL
|
val reason = if (isAutomatic) LaunchCancelReason.AUTOMATIC else LaunchCancelReason.MANUAL
|
||||||
|
|
||||||
isCancelling = true
|
isCancelling = true
|
||||||
_uiState.emit(LaunchUIState.Cancelled(reason, true))
|
|
||||||
|
|
||||||
val releaseManager = ReleaseManager.get(application)
|
val releaseManager = ReleaseManager.get(application)
|
||||||
if (releaseManager.isInUpdate) {
|
if (releaseManager.isInUpdate) {
|
||||||
|
_uiState.tryEmit(LaunchUIState.Cancelled(reason, true))
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
releaseManager.cancelUpdate()
|
releaseManager.cancelUpdate()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_uiState.emit(LaunchUIState.Cancelled(reason, false))
|
_uiState.tryEmit(LaunchUIState.Cancelled(reason, false))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
|
|||||||
@@ -1,46 +1,76 @@
|
|||||||
package com.geode.launcher.main
|
package com.geode.launcher.main
|
||||||
|
|
||||||
import android.content.ActivityNotFoundException
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.content.pm.PackageInstaller
|
||||||
import android.provider.DocumentsContract
|
|
||||||
import android.text.format.Formatter
|
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.offset
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ElevatedCard
|
import androidx.compose.material3.ElevatedCard
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
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.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.TextLinkStyles
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.geode.launcher.InstallReceiver
|
||||||
import com.geode.launcher.R
|
import com.geode.launcher.R
|
||||||
import com.geode.launcher.UserDirectoryProvider
|
import com.geode.launcher.main.LauncherUpdater.downloadUpdate
|
||||||
|
import com.geode.launcher.ui.theme.Typography
|
||||||
import com.geode.launcher.updater.ReleaseManager
|
import com.geode.launcher.updater.ReleaseManager
|
||||||
import com.geode.launcher.updater.ReleaseViewModel
|
|
||||||
import com.geode.launcher.utils.LaunchUtils
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
import com.geode.launcher.utils.PreferenceUtils
|
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.io.File
|
||||||
import java.net.ConnectException
|
import java.text.DateFormat
|
||||||
import java.net.UnknownHostException
|
import java.util.Date
|
||||||
|
import kotlin.time.toJavaInstant
|
||||||
|
|
||||||
fun clearDownloadedApks(context: Context) {
|
fun clearDownloadedApks(context: Context) {
|
||||||
// technically we should be using the activity results but it was too inconsistent for my liking
|
// technically we should be using the activity results but it was too inconsistent for my liking
|
||||||
@@ -59,52 +89,327 @@ fun clearDownloadedApks(context: Context) {
|
|||||||
it.delete()
|
it.delete()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
context.filesDir.listFiles { _, name -> name.lowercase().endsWith(".apk") }?.forEach {
|
||||||
|
it.delete()
|
||||||
|
}
|
||||||
|
|
||||||
preferenceUtils.setBoolean(PreferenceUtils.Key.CLEANUP_APKS, false)
|
preferenceUtils.setBoolean(PreferenceUtils.Key.CLEANUP_APKS, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun generateInstallIntent(uri: Uri): Intent {
|
// lazy singleton idc
|
||||||
// maybe one day i'll rewrite this to use packageinstaller. not today
|
object LauncherUpdater {
|
||||||
return Intent(Intent.ACTION_INSTALL_PACKAGE).apply {
|
enum class LauncherUpdateState {
|
||||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
Began,
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun installLauncherUpdate(context: Context) {
|
suspend fun installPackage(context: Context, file: File) {
|
||||||
val nextUpdate = ReleaseManager.get(context).availableLauncherUpdate.value
|
_uiState.emit(LauncherUpdateState.Began)
|
||||||
val launcherDownload = nextUpdate?.getLauncherDownload()
|
|
||||||
|
|
||||||
if (launcherDownload != null) {
|
val installer = context.packageManager.packageInstaller
|
||||||
val outputFile = launcherDownload.name
|
|
||||||
val baseDirectory = LaunchUtils.getBaseDirectory(context)
|
|
||||||
|
|
||||||
val outputPathFile = File(baseDirectory, outputFile)
|
installer.registerSessionCallback(object : PackageInstaller.SessionCallback() {
|
||||||
if (!outputPathFile.exists()) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val outputPath = "${UserDirectoryProvider.ROOT}${outputFile}"
|
if (!launcherDownload.exists()) {
|
||||||
|
return
|
||||||
val uri = DocumentsContract.buildDocumentUri("${context.packageName}.user", outputPath)
|
}
|
||||||
|
|
||||||
PreferenceUtils.get(context).setBoolean(PreferenceUtils.Key.CLEANUP_APKS, true)
|
PreferenceUtils.get(context).setBoolean(PreferenceUtils.Key.CLEANUP_APKS, true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val intent = generateInstallIntent(uri)
|
LauncherUpdater.installPackage(context, launcherDownload)
|
||||||
context.startActivity(intent)
|
} catch (e: Exception) {
|
||||||
} catch (e: ActivityNotFoundException) {
|
e.printStackTrace()
|
||||||
Toast.makeText(context, context.getString(R.string.no_activity_found), Toast.LENGTH_SHORT).show()
|
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 {
|
} else {
|
||||||
Toast.makeText(context, context.getString(R.string.release_fetch_no_releases), Toast.LENGTH_SHORT).show()
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 12.dp)
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.size(4.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LauncherUpdateIndicator(modifier: Modifier = Modifier) {
|
fun LauncherUpdateIndicator(modifier: Modifier = Modifier) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
ElevatedCard(modifier) {
|
var showInfoDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
if (showInfoDialog) {
|
||||||
|
LauncherUpdateInformation {
|
||||||
|
showInfoDialog = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var updateJob by remember { mutableStateOf<Job?>(null) }
|
||||||
|
var cancellingUpdate by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
var beginUpdate by remember { mutableStateOf(false) }
|
||||||
|
if (beginUpdate) {
|
||||||
|
LauncherUpdateDialog(cancellingUpdate) {
|
||||||
|
cancellingUpdate = true
|
||||||
|
coroutineScope.launch {
|
||||||
|
updateJob?.cancelAndJoin()
|
||||||
|
updateJob = null
|
||||||
|
|
||||||
|
beginUpdate = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// there's like a single pixel of the shadow that gets clipped without this...
|
||||||
|
ElevatedCard(modifier = modifier.padding(bottom = 1.dp)) {
|
||||||
Column(
|
Column(
|
||||||
// buttons add enough padding already, lower to compensate
|
// buttons add enough padding already, lower to compensate
|
||||||
modifier = Modifier.padding(
|
modifier = Modifier.padding(
|
||||||
@@ -120,178 +425,26 @@ fun LauncherUpdateIndicator(modifier: Modifier = Modifier) {
|
|||||||
modifier = Modifier.padding(horizontal = 10.dp)
|
modifier = Modifier.padding(horizontal = 10.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Row(modifier = Modifier.align(Alignment.End)) {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = { installLauncherUpdate(context) },
|
onClick = { showInfoDialog = true },
|
||||||
modifier = Modifier.align(Alignment.End)
|
) {
|
||||||
|
Text(stringResource(R.string.launcher_update_view))
|
||||||
|
}
|
||||||
|
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
updateJob = coroutineScope.launch {
|
||||||
|
cancellingUpdate = false
|
||||||
|
beginUpdate = true
|
||||||
|
|
||||||
|
installLauncherUpdate(context)
|
||||||
|
}
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.launcher_install))
|
Text(stringResource(R.string.launcher_install))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun UpdateProgressIndicator(
|
|
||||||
message: String,
|
|
||||||
releaseViewModel: ReleaseViewModel,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
progress: (() -> Float)? = null
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
horizontalAlignment = Alignment.Start,
|
|
||||||
modifier = modifier
|
|
||||||
) {
|
|
||||||
Text(message)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.padding(4.dp))
|
|
||||||
|
|
||||||
if (progress == null) {
|
|
||||||
LinearProgressIndicator()
|
|
||||||
} else {
|
|
||||||
LinearProgressIndicator(progress = progress)
|
|
||||||
}
|
|
||||||
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
releaseViewModel.cancelUpdate()
|
|
||||||
},
|
|
||||||
modifier = Modifier.offset((-12).dp)
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.release_fetch_button_cancel))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun UpdateMessageIndicator(
|
|
||||||
message: String,
|
|
||||||
releaseViewModel: ReleaseViewModel,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
allowRetry: Boolean = false
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
modifier = modifier
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
message,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.padding(4.dp))
|
|
||||||
|
|
||||||
if (allowRetry) {
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = {
|
|
||||||
releaseViewModel.runReleaseCheck(true)
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Filled.Refresh,
|
|
||||||
contentDescription = stringResource(R.string.launcher_retry_icon_alt)
|
|
||||||
)
|
|
||||||
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
|
||||||
Text(stringResource(R.string.release_fetch_button_retry))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun UpdateCard(releaseViewModel: ReleaseViewModel, modifier: Modifier = Modifier) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
val releaseState by releaseViewModel.uiState.collectAsState()
|
|
||||||
|
|
||||||
when (val state = releaseState) {
|
|
||||||
is ReleaseViewModel.ReleaseUIState.Failure -> {
|
|
||||||
val message = when (state.exception) {
|
|
||||||
is UnknownHostException, is ConnectException ->
|
|
||||||
stringResource(R.string.release_fetch_no_internet)
|
|
||||||
is ReleaseManager.UpdateException -> {
|
|
||||||
when (state.exception.reason) {
|
|
||||||
ReleaseManager.UpdateException.Reason.EXTERNAL_FILE_IN_USE -> stringResource(
|
|
||||||
R.string.release_fetch_manual_check_required
|
|
||||||
)
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
UpdateMessageIndicator(
|
|
||||||
stringResource(
|
|
||||||
R.string.release_fetch_failed,
|
|
||||||
message ?: stringResource(R.string.release_fetch_try_later)
|
|
||||||
),
|
|
||||||
modifier = modifier,
|
|
||||||
allowRetry = true,
|
|
||||||
releaseViewModel = releaseViewModel
|
|
||||||
)
|
|
||||||
}
|
|
||||||
is ReleaseViewModel.ReleaseUIState.InDownload -> {
|
|
||||||
val downloaded = remember(state.downloaded) {
|
|
||||||
Formatter.formatShortFileSize(context, state.downloaded)
|
|
||||||
}
|
|
||||||
|
|
||||||
val outOf = remember(state.outOf) {
|
|
||||||
Formatter.formatShortFileSize(context, state.outOf)
|
|
||||||
}
|
|
||||||
|
|
||||||
UpdateProgressIndicator(
|
|
||||||
stringResource(
|
|
||||||
R.string.release_fetch_downloading,
|
|
||||||
downloaded,
|
|
||||||
outOf
|
|
||||||
),
|
|
||||||
modifier = modifier,
|
|
||||||
releaseViewModel = releaseViewModel,
|
|
||||||
progress = {
|
|
||||||
val progress = state.downloaded / state.outOf.toDouble()
|
|
||||||
progress.toFloat()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
is ReleaseViewModel.ReleaseUIState.InUpdateCheck -> {
|
|
||||||
UpdateProgressIndicator(
|
|
||||||
stringResource(R.string.release_fetch_in_progress),
|
|
||||||
modifier = modifier,
|
|
||||||
releaseViewModel = releaseViewModel
|
|
||||||
)
|
|
||||||
}
|
|
||||||
is ReleaseViewModel.ReleaseUIState.Finished -> {
|
|
||||||
if (state.hasUpdated) {
|
|
||||||
UpdateMessageIndicator(
|
|
||||||
stringResource(R.string.release_fetch_success),
|
|
||||||
modifier = modifier,
|
|
||||||
releaseViewModel = releaseViewModel
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is ReleaseViewModel.ReleaseUIState.Cancelled -> {
|
|
||||||
if (state.isCancelling) {
|
|
||||||
UpdateProgressIndicator(
|
|
||||||
stringResource(R.string.release_fetch_cancelling),
|
|
||||||
modifier = modifier,
|
|
||||||
releaseViewModel = releaseViewModel
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
UpdateMessageIndicator(
|
|
||||||
stringResource(R.string.release_fetch_cancelled),
|
|
||||||
modifier = modifier,
|
|
||||||
allowRetry = true,
|
|
||||||
releaseViewModel = releaseViewModel
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val nextUpdate by releaseViewModel.nextLauncherUpdate.collectAsState()
|
|
||||||
|
|
||||||
if (!releaseViewModel.isInUpdate && nextUpdate != null) {
|
|
||||||
LauncherUpdateIndicator(
|
|
||||||
modifier = modifier
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
package com.geode.launcher.preferences
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.OnBackPressedDispatcher
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.geode.launcher.R
|
||||||
|
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||||
|
import com.geode.launcher.ui.theme.LocalTheme
|
||||||
|
import com.geode.launcher.ui.theme.Theme
|
||||||
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import com.geode.launcher.utils.ApplicationIcon
|
||||||
|
import com.geode.launcher.utils.ApplicationIconDetails
|
||||||
|
import com.geode.launcher.utils.IconUtils
|
||||||
|
import com.geode.launcher.utils.adaptiveIconPainterResource
|
||||||
|
|
||||||
|
class ApplicationIconActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContent {
|
||||||
|
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
|
||||||
|
val theme = Theme.fromInt(themeOption)
|
||||||
|
|
||||||
|
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
|
||||||
|
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalTheme provides theme) {
|
||||||
|
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
ApplicationIconScreen(onBackPressedDispatcher)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun IconSelectConfirmDialog(selectedIcon: ApplicationIconDetails, onDismissRequest: () -> Unit) {
|
||||||
|
AlertDialog(
|
||||||
|
icon = {
|
||||||
|
Image(
|
||||||
|
adaptiveIconPainterResource(selectedIcon.iconId),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(stringResource(
|
||||||
|
R.string.application_icon_confirm,
|
||||||
|
stringResource(selectedIcon.nameId)
|
||||||
|
))
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(stringResource(R.string.application_icon_confirm_description))
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismissRequest) {
|
||||||
|
Text(stringResource(R.string.message_box_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
TextButton(onClick = { onSelectIcon(context, selectedIcon) }) {
|
||||||
|
Text(stringResource(R.string.message_box_accept))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismissRequest = onDismissRequest
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSelectIcon(context: Context, selectedIcon: ApplicationIconDetails) {
|
||||||
|
val packageManager = context.packageManager
|
||||||
|
|
||||||
|
try {
|
||||||
|
ApplicationIcon.entries.forEach {
|
||||||
|
val iconData = IconUtils.getIconDetails(it)
|
||||||
|
|
||||||
|
packageManager.setComponentEnabledSetting(
|
||||||
|
ComponentName(context, "${context.packageName.removeSuffix(".play")}.${iconData.component}"),
|
||||||
|
if (iconData.id == selectedIcon.id)
|
||||||
|
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||||
|
else PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
|
||||||
|
PackageManager.DONT_KILL_APP
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: IllegalArgumentException) {
|
||||||
|
Toast.makeText(context, R.string.application_icon_set_failed, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
PreferenceUtils.get(context).setString(PreferenceUtils.Key.SELECTED_ICON, selectedIcon.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun IconCard(iconData: ApplicationIconDetails, selected: Boolean, modifier: Modifier = Modifier) {
|
||||||
|
var showSelection by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
if (showSelection) {
|
||||||
|
IconSelectConfirmDialog(iconData) {
|
||||||
|
showSelection = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 1.dp)
|
||||||
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.then(modifier)
|
||||||
|
.selectable(
|
||||||
|
selected = selected,
|
||||||
|
onClick = {
|
||||||
|
if (!selected)
|
||||||
|
showSelection = true
|
||||||
|
},
|
||||||
|
role = Role.RadioButton
|
||||||
|
)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
Arrangement.SpaceBetween,
|
||||||
|
Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
adaptiveIconPainterResource(iconData.iconId),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
Text(stringResource(iconData.nameId))
|
||||||
|
}
|
||||||
|
|
||||||
|
RadioButton(
|
||||||
|
selected = selected,
|
||||||
|
onClick = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ApplicationIconScreen(
|
||||||
|
onBackPressedDispatcher: OnBackPressedDispatcher?,
|
||||||
|
) {
|
||||||
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
|
topBar = {
|
||||||
|
Column {
|
||||||
|
TopAppBar(
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_arrow_back),
|
||||||
|
contentDescription = stringResource(R.string.back_icon_alt)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.title_activity_application_icon),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
},
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.SpaceEvenly,
|
||||||
|
) {
|
||||||
|
val selectedIcon by PreferenceUtils.useStringPreference(PreferenceUtils.Key.SELECTED_ICON)
|
||||||
|
|
||||||
|
Column(Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
) {
|
||||||
|
ApplicationIcon.entries.forEach {
|
||||||
|
val iconData = IconUtils.getIconDetails(it)
|
||||||
|
IconCard(iconData, selectedIcon == iconData.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.geode.launcher.preferences
|
package com.geode.launcher.preferences
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
@@ -9,7 +8,6 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
|||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -18,11 +16,7 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
||||||
import androidx.compose.material.icons.filled.Check
|
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
|
||||||
import androidx.compose.material.icons.filled.Share
|
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -47,16 +41,11 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
import androidx.compose.ui.platform.LocalResources
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
@@ -66,10 +55,13 @@ import com.geode.launcher.R
|
|||||||
import com.geode.launcher.log.LogLine
|
import com.geode.launcher.log.LogLine
|
||||||
import com.geode.launcher.log.LogPriority
|
import com.geode.launcher.log.LogPriority
|
||||||
import com.geode.launcher.log.LogViewModel
|
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.GeodeLauncherTheme
|
||||||
import com.geode.launcher.ui.theme.LocalTheme
|
import com.geode.launcher.ui.theme.LocalTheme
|
||||||
import com.geode.launcher.ui.theme.Theme
|
import com.geode.launcher.ui.theme.Theme
|
||||||
import com.geode.launcher.ui.theme.Typography
|
import com.geode.launcher.ui.theme.Typography
|
||||||
|
import com.geode.launcher.ui.theme.robotoMonoFamily
|
||||||
import com.geode.launcher.utils.PreferenceUtils
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -83,9 +75,10 @@ class ApplicationLogsActivity : ComponentActivity() {
|
|||||||
val theme = Theme.fromInt(themeOption)
|
val theme = Theme.fromInt(themeOption)
|
||||||
|
|
||||||
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
|
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
|
||||||
|
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
|
||||||
|
|
||||||
CompositionLocalProvider(LocalTheme provides theme) {
|
CompositionLocalProvider(LocalTheme provides theme) {
|
||||||
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption) {
|
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
color = MaterialTheme.colorScheme.background
|
color = MaterialTheme.colorScheme.background
|
||||||
@@ -135,45 +128,38 @@ fun SelectLogLevelDialog(
|
|||||||
onDismissRequest = onDismissRequest,
|
onDismissRequest = onDismissRequest,
|
||||||
onSelect = { onSelect(it) },
|
onSelect = { onSelect(it) },
|
||||||
initialValue = initialValue,
|
initialValue = initialValue,
|
||||||
toLabel = {
|
) {
|
||||||
// i'm lazy
|
(LogPriority.VERBOSE..LogPriority.FATAL).reversed().forEach {
|
||||||
it.name.lowercase().replaceFirstChar { c -> c.uppercaseChar() }
|
SelectOption(
|
||||||
},
|
name = it.name.lowercase().replaceFirstChar { c -> c.uppercaseChar() },
|
||||||
optionsCount = LogPriority.FATAL downTo LogPriority.VERBOSE
|
value = it
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LogCard(logLine: LogLine, modifier: Modifier = Modifier) {
|
fun LogCard(logLine: LogLine, modifier: Modifier = Modifier) {
|
||||||
val haptics = LocalHapticFeedback.current
|
SelectionContainer {
|
||||||
val clipboardManager = LocalClipboardManager.current
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.padding(8.dp)
|
.padding(8.dp)
|
||||||
.pointerInput(Unit) {
|
|
||||||
detectTapGestures(
|
|
||||||
onLongPress = {
|
|
||||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
|
||||||
clipboardManager.setText(AnnotatedString(logLine.asSimpleString))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
"[${logLine.priority.toChar()}] ${logLine.formattedTime}",
|
"[${logLine.priority.toChar()}] ${logLine.formattedTime}",
|
||||||
style = Typography.labelSmall,
|
style = Typography.labelSmall,
|
||||||
color = getLogColor(logLine.priority),
|
color = getLogColor(logLine.priority),
|
||||||
fontFamily = FontFamily.Monospace
|
fontFamily = robotoMonoFamily
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
"[${logLine.tag}]: ${logLine.messageTrimmed}",
|
"[${logLine.tag}]: ${logLine.messageTrimmed}",
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = robotoMonoFamily,
|
||||||
style = Typography.bodyMedium
|
style = Typography.bodyMedium
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -185,6 +171,7 @@ fun ApplicationLogsScreen(
|
|||||||
val isLoading by logViewModel.isLoading.collectAsState()
|
val isLoading by logViewModel.isLoading.collectAsState()
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val resources = LocalResources.current
|
||||||
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
@@ -232,7 +219,7 @@ fun ApplicationLogsScreen(
|
|||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
|
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
painterResource(R.drawable.icon_arrow_back),
|
||||||
contentDescription = stringResource(R.string.back_icon_alt)
|
contentDescription = stringResource(R.string.back_icon_alt)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -240,26 +227,17 @@ fun ApplicationLogsScreen(
|
|||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
val data = logViewModel.getLogData()
|
saveLauncher.launch(resources.getString(R.string.application_logs_default_filename))
|
||||||
|
|
||||||
val sendIntent = Intent(Intent.ACTION_SEND).apply {
|
|
||||||
putExtra(Intent.EXTRA_TEXT, data)
|
|
||||||
type = "text/plain"
|
|
||||||
}
|
|
||||||
|
|
||||||
val shareIntent = Intent.createChooser(sendIntent, null)
|
|
||||||
|
|
||||||
context.startActivity(shareIntent)
|
|
||||||
}
|
}
|
||||||
}, enabled = !logLines.isEmpty()) {
|
}, enabled = !logLines.isEmpty()) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.Share,
|
painterResource(R.drawable.icon_save),
|
||||||
contentDescription = stringResource(R.string.application_logs_share)
|
contentDescription = stringResource(R.string.application_logs_export),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
IconButton(onClick = { showMoreOptions = !showMoreOptions }) {
|
IconButton(onClick = { showMoreOptions = !showMoreOptions }) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.MoreVert,
|
painterResource(R.drawable.icon_more_vert),
|
||||||
contentDescription = stringResource(R.string.application_logs_more)
|
contentDescription = stringResource(R.string.application_logs_more)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -278,7 +256,7 @@ fun ApplicationLogsScreen(
|
|||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
if (logViewModel.filterCrashes) {
|
if (logViewModel.filterCrashes) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.Check,
|
painterResource(R.drawable.icon_check),
|
||||||
contentDescription = stringResource(R.string.application_logs_crash_only_enabled_alt)
|
contentDescription = stringResource(R.string.application_logs_crash_only_enabled_alt)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -307,23 +285,6 @@ fun ApplicationLogsScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
DropdownMenuItem(
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
|
||||||
painterResource(R.drawable.icon_save),
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Text(stringResource(R.string.application_logs_export))
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
saveLauncher.launch(context.getString(R.string.application_logs_default_filename))
|
|
||||||
showMoreOptions = false
|
|
||||||
},
|
|
||||||
enabled = !logLines.isEmpty()
|
|
||||||
)
|
|
||||||
|
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
leadingIcon = {
|
leadingIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
package com.geode.launcher.preferences
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
import android.widget.Toast
|
||||||
|
import java.text.DateFormat
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.OnBackPressedDispatcher
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.geode.launcher.R
|
||||||
|
import com.geode.launcher.UserDirectoryProvider
|
||||||
|
import com.geode.launcher.log.CrashDump
|
||||||
|
import com.geode.launcher.log.CrashViewModel
|
||||||
|
import com.geode.launcher.preferences.components.DirectoryListingScreen
|
||||||
|
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||||
|
import com.geode.launcher.ui.theme.LocalTheme
|
||||||
|
import com.geode.launcher.ui.theme.Theme
|
||||||
|
import com.geode.launcher.ui.theme.robotoMonoFamily
|
||||||
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
|
import java.io.File
|
||||||
|
import java.util.Date
|
||||||
|
import kotlin.time.toJavaInstant
|
||||||
|
|
||||||
|
class CrashLogsActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContent {
|
||||||
|
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
|
||||||
|
val theme = Theme.fromInt(themeOption)
|
||||||
|
|
||||||
|
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
|
||||||
|
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalTheme provides theme) {
|
||||||
|
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
CrashLogsScreen(onBackPressedDispatcher)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shareCrash(context: Context, filename: String) {
|
||||||
|
val baseDirectory = LaunchUtils.getBaseDirectory(context, true)
|
||||||
|
val crashPath = File(LaunchUtils.getCrashDirectory(context), filename)
|
||||||
|
|
||||||
|
if (!crashPath.exists()) {
|
||||||
|
Toast.makeText(context, R.string.launcher_error_export_missing, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val providerLocation = crashPath.toRelativeString(baseDirectory)
|
||||||
|
val documentsPath = "${UserDirectoryProvider.ROOT}${providerLocation}"
|
||||||
|
|
||||||
|
val uri = DocumentsContract.buildDocumentUri("${context.packageName}.user", documentsPath)
|
||||||
|
|
||||||
|
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||||
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
|
type = "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
context.startActivity(shareIntent)
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
Toast.makeText(context, R.string.no_activity_found, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CrashCard(crashDump: CrashDump, crashViewModel: CrashViewModel, modifier: Modifier = Modifier) {
|
||||||
|
val isReadable = crashDump.filename.endsWith(".log")
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 1.dp, horizontal = 8.dp)
|
||||||
|
.height(64.dp)
|
||||||
|
.then(modifier)
|
||||||
|
.clickable(isReadable) {
|
||||||
|
val launchIntent = Intent(context, TextViewActivity::class.java).run {
|
||||||
|
putExtra(TextViewActivity.EXTRA_LOG_VIEW_FILENAME, crashDump.fullPath)
|
||||||
|
}
|
||||||
|
context.startActivity(launchIntent)
|
||||||
|
}
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerLow)
|
||||||
|
.padding(vertical = 8.dp, horizontal = 8.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.SpaceAround,
|
||||||
|
modifier = Modifier.fillMaxHeight().offset(x = 12.dp).weight(0.7f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
crashDump.filename,
|
||||||
|
fontFamily = robotoMonoFamily,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
val formattedLastModified = remember {
|
||||||
|
DateFormat.getDateTimeInstance()
|
||||||
|
.format(
|
||||||
|
Date.from(crashDump.lastModified.toJavaInstant())
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
Text(formattedLastModified, style = MaterialTheme.typography.labelMedium)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(modifier = Modifier.weight(0.3f), horizontalArrangement = Arrangement.End) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
IconButton(onClick = {
|
||||||
|
shareCrash(context, crashDump.filename)
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_share),
|
||||||
|
contentDescription = stringResource(R.string.application_logs_share)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(onClick = {
|
||||||
|
crashViewModel.removeFile(crashDump.filename)
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_delete),
|
||||||
|
contentDescription = stringResource(R.string.application_crashes_delete_one)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CrashLogsScreen(
|
||||||
|
onBackPressedDispatcher: OnBackPressedDispatcher?,
|
||||||
|
crashViewModel: CrashViewModel = viewModel(factory = CrashViewModel.Factory)
|
||||||
|
) {
|
||||||
|
DirectoryListingScreen(
|
||||||
|
onBackPressedDispatcher,
|
||||||
|
crashViewModel,
|
||||||
|
titleId = R.string.title_activity_crash_logs,
|
||||||
|
noneAvailableId = R.string.application_crashes_no_dumps,
|
||||||
|
additionalOptions = { onDismiss ->
|
||||||
|
val developerMode by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DEVELOPER_MODE)
|
||||||
|
if (developerMode) {
|
||||||
|
if (crashViewModel.hasIndicator) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_remove_selection),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(stringResource(R.string.application_crashes_clear_indicator))
|
||||||
|
}, onClick = {
|
||||||
|
crashViewModel.clearIndicator()
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
DropdownMenuItem(
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_skull),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(stringResource(R.string.application_crashes_create_indicator))
|
||||||
|
}, onClick = {
|
||||||
|
crashViewModel.createIndicator()
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { line, shape ->
|
||||||
|
CrashCard(
|
||||||
|
line,
|
||||||
|
crashViewModel,
|
||||||
|
modifier = Modifier.clip(shape)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showSystemUi = true)
|
||||||
|
@Composable
|
||||||
|
fun CrashLogsPreview() {
|
||||||
|
GeodeLauncherTheme {
|
||||||
|
CrashLogsScreen(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,23 +1,26 @@
|
|||||||
package com.geode.launcher.preferences
|
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.os.Bundle
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.OnBackPressedDispatcher
|
import androidx.activity.OnBackPressedDispatcher
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
||||||
import androidx.compose.material.icons.filled.Info
|
|
||||||
import androidx.compose.material3.ElevatedCard
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -31,14 +34,25 @@ import androidx.compose.runtime.CompositionLocalProvider
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
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.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.geode.launcher.BuildConfig
|
||||||
import com.geode.launcher.R
|
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.GeodeLauncherTheme
|
||||||
import com.geode.launcher.ui.theme.LocalTheme
|
import com.geode.launcher.ui.theme.LocalTheme
|
||||||
import com.geode.launcher.ui.theme.Theme
|
import com.geode.launcher.ui.theme.Theme
|
||||||
import com.geode.launcher.utils.LabelledText
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
import com.geode.launcher.utils.PreferenceUtils
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
|
|
||||||
class DeveloperSettingsActivity : ComponentActivity() {
|
class DeveloperSettingsActivity : ComponentActivity() {
|
||||||
@@ -51,9 +65,10 @@ class DeveloperSettingsActivity : ComponentActivity() {
|
|||||||
val theme = Theme.fromInt(themeOption)
|
val theme = Theme.fromInt(themeOption)
|
||||||
|
|
||||||
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
|
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
|
||||||
|
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
|
||||||
|
|
||||||
CompositionLocalProvider(LocalTheme provides theme) {
|
CompositionLocalProvider(LocalTheme provides theme) {
|
||||||
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption) {
|
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
color = MaterialTheme.colorScheme.background
|
color = MaterialTheme.colorScheme.background
|
||||||
@@ -66,11 +81,28 @@ class DeveloperSettingsActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
fun onOpenFolder(context: Context) {
|
||||||
fun releaseChannelToKey(option: Int): String = when (option) {
|
val file = LaunchUtils.getBaseDirectory(context)
|
||||||
1 -> stringResource(R.string.preference_release_channel_beta)
|
val clipboardManager =
|
||||||
2 -> stringResource(R.string.preference_release_channel_nightly)
|
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
else -> stringResource(R.string.preference_release_channel_stable)
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -86,7 +118,7 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
|
|||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
|
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
painterResource(R.drawable.icon_arrow_back),
|
||||||
contentDescription = stringResource(R.string.back_icon_alt)
|
contentDescription = stringResource(R.string.back_icon_alt)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -99,6 +131,7 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(MaterialTheme.colorScheme.background),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,16 +146,12 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
|
|||||||
|
|
||||||
val developerMode by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DEVELOPER_MODE)
|
val developerMode by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DEVELOPER_MODE)
|
||||||
|
|
||||||
|
OptionsGroup {
|
||||||
SettingsCard(
|
SettingsCard(
|
||||||
title = stringResource(R.string.preference_developer_mode),
|
title = stringResource(R.string.preference_developer_mode),
|
||||||
preferenceKey = PreferenceUtils.Key.DEVELOPER_MODE,
|
preferenceKey = PreferenceUtils.Key.DEVELOPER_MODE,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
InlineText(
|
|
||||||
stringResource(R.string.preference_developer_mode_about)
|
|
||||||
)
|
|
||||||
|
|
||||||
HorizontalDivider()
|
|
||||||
|
|
||||||
if (developerMode) {
|
if (developerMode) {
|
||||||
OptionsGroup(title = stringResource(R.string.preference_category_gameplay)) {
|
OptionsGroup(title = stringResource(R.string.preference_category_gameplay)) {
|
||||||
@@ -130,45 +159,118 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
|
|||||||
title = stringResource(R.string.preference_launch_arguments_name),
|
title = stringResource(R.string.preference_launch_arguments_name),
|
||||||
dialogTitle = stringResource(R.string.preference_launch_arguments_set_title),
|
dialogTitle = stringResource(R.string.preference_launch_arguments_set_title),
|
||||||
preferenceKey = PreferenceUtils.Key.LAUNCH_ARGUMENTS,
|
preferenceKey = PreferenceUtils.Key.LAUNCH_ARGUMENTS,
|
||||||
filterInput = { it.filter { c ->
|
filterInput = {
|
||||||
|
it.filter { c ->
|
||||||
// if only there was a better way to define this!
|
// if only there was a better way to define this!
|
||||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890!@#$%^&*(){}<>[]?:;'\"~`-_+=\\| ".contains(c)
|
"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(
|
SettingsCard(
|
||||||
title = stringResource(R.string.preference_override_exceptions_name),
|
title = stringResource(R.string.preference_override_exceptions_name),
|
||||||
description = stringResource(R.string.preference_override_exceptions_description),
|
description = stringResource(R.string.preference_override_exceptions_description),
|
||||||
preferenceKey = PreferenceUtils.Key.CUSTOM_SYMBOL_LIST
|
preferenceKey = PreferenceUtils.Key.CUSTOM_SYMBOL_LIST
|
||||||
)
|
)
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
|
OptionsGroup(stringResource(R.string.preference_category_developer)) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
OptionsButton(
|
||||||
|
title = stringResource(R.string.preferences_view_logs),
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_mobile_code),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { onOpenLogs(context) }
|
||||||
|
)
|
||||||
|
|
||||||
|
OptionsButton(
|
||||||
|
title = stringResource(R.string.preferences_copy_external_button),
|
||||||
|
description = LaunchUtils.getBaseDirectory(context).path,
|
||||||
|
onClick = { onOpenFolder(context) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!BuildConfig.GOOGLE_PLAY_BUILD) {
|
||||||
OptionsGroup(title = stringResource(R.string.preference_category_updater)) {
|
OptionsGroup(title = stringResource(R.string.preference_category_updater)) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
SettingsSelectCard(
|
SettingsSelectCard(
|
||||||
title = stringResource(R.string.preference_release_channel_tag_name),
|
title = stringResource(R.string.preference_release_channel_tag_name),
|
||||||
dialogTitle = stringResource(R.string.preference_release_channel_select),
|
dialogTitle = stringResource(R.string.preference_release_channel_select),
|
||||||
maxVal = 2,
|
|
||||||
preferenceKey = PreferenceUtils.Key.RELEASE_CHANNEL_TAG,
|
preferenceKey = PreferenceUtils.Key.RELEASE_CHANNEL_TAG,
|
||||||
toLabel = { releaseChannelToKey(it) }
|
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)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
ElevatedCard(modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) {
|
SettingsCard(
|
||||||
LabelledText(
|
title = stringResource(R.string.preference_disable_update_cache),
|
||||||
label = stringResource(R.string.preference_developer_update_notice),
|
preferenceKey = PreferenceUtils.Key.DISABLE_UPDATE_CACHE
|
||||||
icon = {
|
|
||||||
Icon(Icons.Filled.Info, null)
|
|
||||||
},
|
|
||||||
modifier = Modifier.padding(8.dp)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
OptionsGroup(title = stringResource(R.string.preference_category_testing)) {
|
OptionsGroup(title = stringResource(R.string.preference_category_testing)) {
|
||||||
SettingsCard(
|
SettingsSelectCard(
|
||||||
title = stringResource(R.string.preference_enable_redesign),
|
title = stringResource(R.string.preference_launch_delay),
|
||||||
preferenceKey = PreferenceUtils.Key.ENABLE_REDESIGN,
|
dialogTitle = stringResource(R.string.preference_launch_delay_select),
|
||||||
|
preferenceKey = PreferenceUtils.Key.WAIT_PERIOD,
|
||||||
|
options = linkedMapOf(
|
||||||
|
0 to stringResource(R.string.preference_launch_delay_instant),
|
||||||
|
1 to pluralStringResource(
|
||||||
|
R.plurals.preference_launch_delay_value,
|
||||||
|
1,
|
||||||
|
1
|
||||||
|
),
|
||||||
|
3 to pluralStringResource(
|
||||||
|
R.plurals.preference_launch_delay_value,
|
||||||
|
3,
|
||||||
|
3
|
||||||
|
),
|
||||||
|
5 to pluralStringResource(
|
||||||
|
R.plurals.preference_launch_delay_value,
|
||||||
|
5,
|
||||||
|
5
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
)
|
||||||
}
|
|
||||||
|
/*
|
||||||
|
SettingsCard(
|
||||||
|
title = stringResource(R.string.preference_disable_redesign),
|
||||||
|
preferenceKey = PreferenceUtils.Key.DISABLE_REDESIGN,
|
||||||
|
)
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
OptionsGroup(title = stringResource(R.string.preference_profiles)) {
|
||||||
|
ProfileCreateCard()
|
||||||
|
ProfileSelectCard()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package com.geode.launcher.preferences
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
import android.text.format.Formatter
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.OnBackPressedDispatcher
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.geode.launcher.R
|
||||||
|
import com.geode.launcher.UserDirectoryProvider
|
||||||
|
import com.geode.launcher.log.GeodeLog
|
||||||
|
import com.geode.launcher.log.GeodeLogsViewModel
|
||||||
|
import com.geode.launcher.preferences.components.DirectoryListingScreen
|
||||||
|
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||||
|
import com.geode.launcher.ui.theme.LocalTheme
|
||||||
|
import com.geode.launcher.ui.theme.Theme
|
||||||
|
import com.geode.launcher.ui.theme.robotoMonoFamily
|
||||||
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class GeodeLogsListingActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContent {
|
||||||
|
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
|
||||||
|
val theme = Theme.fromInt(themeOption)
|
||||||
|
|
||||||
|
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
|
||||||
|
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalTheme provides theme) {
|
||||||
|
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
GeodeLogsListingScreen(onBackPressedDispatcher)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shareLog(context: Context, filename: String) {
|
||||||
|
val baseDirectory = LaunchUtils.getBaseDirectory(context, true)
|
||||||
|
val crashPath = File(LaunchUtils.getGeodeLogsDirectory(context), filename)
|
||||||
|
|
||||||
|
if (!crashPath.exists()) {
|
||||||
|
Toast.makeText(context, R.string.launcher_error_export_missing, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val providerLocation = crashPath.toRelativeString(baseDirectory)
|
||||||
|
val documentsPath = "${UserDirectoryProvider.ROOT}${providerLocation}"
|
||||||
|
|
||||||
|
val uri = DocumentsContract.buildDocumentUri("${context.packageName}.user", documentsPath)
|
||||||
|
|
||||||
|
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||||
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
|
type = "text/plain"
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
context.startActivity(shareIntent)
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
Toast.makeText(context, R.string.no_activity_found, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GeodeLogCard(geodeLog: GeodeLog, geodeLogsViewModel: GeodeLogsViewModel, modifier: Modifier = Modifier) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 1.dp, horizontal = 8.dp)
|
||||||
|
.height(64.dp)
|
||||||
|
.then(modifier)
|
||||||
|
.clickable {
|
||||||
|
val launchIntent = Intent(context, TextViewActivity::class.java).run {
|
||||||
|
putExtra(TextViewActivity.EXTRA_LOG_VIEW_FILENAME, geodeLog.fullPath)
|
||||||
|
}
|
||||||
|
context.startActivity(launchIntent)
|
||||||
|
}
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerLow)
|
||||||
|
.padding(vertical = 8.dp, horizontal = 8.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.SpaceAround,
|
||||||
|
modifier = Modifier.fillMaxHeight().offset(x = 12.dp).weight(0.7f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
geodeLog.filename,
|
||||||
|
fontFamily = robotoMonoFamily,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
val formattedSize = remember(geodeLog.fileSize) {
|
||||||
|
Formatter.formatShortFileSize(context, geodeLog.fileSize)
|
||||||
|
}
|
||||||
|
Text(formattedSize, style = MaterialTheme.typography.labelMedium)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(modifier = Modifier.weight(0.3f), horizontalArrangement = Arrangement.End) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
IconButton(onClick = {
|
||||||
|
shareLog(context, geodeLog.filename)
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_share),
|
||||||
|
contentDescription = stringResource(R.string.application_logs_share)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(onClick = {
|
||||||
|
geodeLogsViewModel.removeFile(geodeLog.filename)
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_delete),
|
||||||
|
contentDescription = stringResource(R.string.application_crashes_delete_one)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GeodeLogsListingScreen(
|
||||||
|
onBackPressedDispatcher: OnBackPressedDispatcher?,
|
||||||
|
geodeLogsViewModel: GeodeLogsViewModel = viewModel(factory = GeodeLogsViewModel.Factory)
|
||||||
|
) {
|
||||||
|
DirectoryListingScreen(
|
||||||
|
onBackPressedDispatcher,
|
||||||
|
geodeLogsViewModel,
|
||||||
|
titleId = R.string.title_activity_geode_logs,
|
||||||
|
noneAvailableId = R.string.application_geode_logs_no_logs,
|
||||||
|
) { line, shape ->
|
||||||
|
GeodeLogCard(
|
||||||
|
line,
|
||||||
|
geodeLogsViewModel,
|
||||||
|
modifier = Modifier.clip(shape)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,12 +3,13 @@ package com.geode.launcher.preferences
|
|||||||
import android.app.UiModeManager
|
import android.app.UiModeManager
|
||||||
import android.content.ActivityNotFoundException
|
import android.content.ActivityNotFoundException
|
||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
import android.content.ClipboardManager
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.provider.DocumentsContract
|
import android.provider.DocumentsContract
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
import android.view.WindowManager
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.OnBackPressedDispatcher
|
import androidx.activity.OnBackPressedDispatcher
|
||||||
@@ -19,42 +20,88 @@ import androidx.compose.animation.animateColorAsState
|
|||||||
import androidx.compose.animation.core.Spring
|
import androidx.compose.animation.core.Spring
|
||||||
import androidx.compose.animation.core.spring
|
import androidx.compose.animation.core.spring
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
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.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.*
|
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.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
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.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalResources
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.Role
|
import androidx.compose.ui.semantics.Role
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.net.toUri
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.geode.launcher.BuildConfig
|
import com.geode.launcher.BuildConfig
|
||||||
import com.geode.launcher.R
|
import com.geode.launcher.R
|
||||||
import com.geode.launcher.UserDirectoryProvider
|
import com.geode.launcher.UserDirectoryProvider
|
||||||
import com.geode.launcher.updater.ReleaseViewModel
|
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.GeodeLauncherTheme
|
||||||
import com.geode.launcher.ui.theme.LocalTheme
|
import com.geode.launcher.ui.theme.LocalTheme
|
||||||
import com.geode.launcher.ui.theme.Theme
|
import com.geode.launcher.ui.theme.Theme
|
||||||
import com.geode.launcher.ui.theme.Typography
|
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.LaunchUtils
|
||||||
import com.geode.launcher.utils.PreferenceUtils
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import java.net.ConnectException
|
import java.net.ConnectException
|
||||||
import java.net.UnknownHostException
|
import java.net.UnknownHostException
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
|
|
||||||
class SettingsActivity : ComponentActivity() {
|
class SettingsActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
@@ -66,9 +113,10 @@ class SettingsActivity : ComponentActivity() {
|
|||||||
val theme = Theme.fromInt(themeOption)
|
val theme = Theme.fromInt(themeOption)
|
||||||
|
|
||||||
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
|
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
|
||||||
|
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
|
||||||
|
|
||||||
CompositionLocalProvider(LocalTheme provides theme) {
|
CompositionLocalProvider(LocalTheme provides theme) {
|
||||||
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption) {
|
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
color = MaterialTheme.colorScheme.background
|
color = MaterialTheme.colorScheme.background
|
||||||
@@ -83,25 +131,6 @@ class SettingsActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 onOpenFileManager(context: Context) {
|
fun onOpenFileManager(context: Context) {
|
||||||
// future thoughts: Intent.ACTION_VIEW may not work on some devices
|
// future thoughts: Intent.ACTION_VIEW may not work on some devices
|
||||||
// the "correct" solution is to go through a list of possible intents/applications
|
// the "correct" solution is to go through a list of possible intents/applications
|
||||||
@@ -122,7 +151,7 @@ fun onOpenFileManager(context: Context) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
context.startActivity(intent)
|
context.startActivity(intent)
|
||||||
} catch (e: ActivityNotFoundException) {
|
} catch (_: ActivityNotFoundException) {
|
||||||
Toast.makeText(
|
Toast.makeText(
|
||||||
context,
|
context,
|
||||||
context.getText(R.string.no_activity_found), Toast.LENGTH_SHORT
|
context.getText(R.string.no_activity_found), Toast.LENGTH_SHORT
|
||||||
@@ -130,14 +159,73 @@ fun onOpenFileManager(context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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
|
@Composable
|
||||||
fun UpdateIndicator(
|
fun UpdateIndicator(
|
||||||
snackbarHostState: SnackbarHostState,
|
snackbarHostState: SnackbarHostState,
|
||||||
updateStatus: ReleaseViewModel.ReleaseUIState
|
updateStatus: ReleaseViewModel.ReleaseUIState
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val resources = LocalResources.current
|
||||||
|
|
||||||
var enablePopup by remember { mutableStateOf(false) }
|
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) {
|
when (updateStatus) {
|
||||||
is ReleaseViewModel.ReleaseUIState.InUpdateCheck -> {
|
is ReleaseViewModel.ReleaseUIState.InUpdateCheck -> {
|
||||||
@@ -147,11 +235,16 @@ fun UpdateIndicator(
|
|||||||
is ReleaseViewModel.ReleaseUIState.InDownload -> {
|
is ReleaseViewModel.ReleaseUIState.InDownload -> {
|
||||||
// is this the ideal design? idk
|
// is this the ideal design? idk
|
||||||
enablePopup = true
|
enablePopup = true
|
||||||
val progress = updateStatus.downloaded / updateStatus.outOf.toDouble()
|
if (updateStatus.outOf != null) {
|
||||||
|
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
progress = { progress.toFloat() },
|
progress = {
|
||||||
|
val progress = updateStatus.downloaded / updateStatus.outOf.toDouble()
|
||||||
|
progress.toFloat()
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
@@ -166,46 +259,40 @@ fun UpdateIndicator(
|
|||||||
is ReleaseViewModel.ReleaseUIState.Failure -> {
|
is ReleaseViewModel.ReleaseUIState.Failure -> {
|
||||||
val message = when (updateStatus.exception) {
|
val message = when (updateStatus.exception) {
|
||||||
is UnknownHostException, is ConnectException ->
|
is UnknownHostException, is ConnectException ->
|
||||||
context.getString(R.string.release_fetch_no_internet)
|
resources.getString(R.string.release_fetch_no_internet)
|
||||||
else -> context.getString(R.string.preference_check_for_updates_failed)
|
else -> resources.getString(R.string.preference_check_for_updates_failed)
|
||||||
}
|
}
|
||||||
|
|
||||||
snackbarHostState.showSnackbar(message)
|
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 -> {
|
is ReleaseViewModel.ReleaseUIState.Finished -> {
|
||||||
if (updateStatus.hasUpdated) {
|
if (updateStatus.hasUpdated) {
|
||||||
snackbarHostState.showSnackbar(
|
snackbarHostState.showSnackbar(
|
||||||
context.getString(R.string.preference_check_for_updates_success)
|
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 {
|
} else {
|
||||||
snackbarHostState.showSnackbar(
|
snackbarHostState.showSnackbar(
|
||||||
context.getString(R.string.preference_check_for_updates_none_found)
|
resources.getString(R.string.preference_check_for_updates_none_found)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
else -> {}
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun displayOptionToKey(option: Int): String {
|
|
||||||
return when (option) {
|
|
||||||
1 -> stringResource(R.string.preference_display_mode_legacy)
|
|
||||||
2 -> stringResource(R.string.preference_display_mode_fullscreen)
|
|
||||||
else -> stringResource(R.string.preference_display_mode_default)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateTheme(context: Context, theme: Int) {
|
fun updateTheme(context: Context, theme: Int) {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
|
val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
|
||||||
@@ -227,26 +314,417 @@ fun updateTheme(context: Context, theme: Int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onOpenLogs(context: Context) {
|
|
||||||
val launchIntent = Intent(context, ApplicationLogsActivity::class.java)
|
|
||||||
context.startActivity(launchIntent)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onOpenDeveloperOptions(context: Context) {
|
fun onOpenDeveloperOptions(context: Context) {
|
||||||
val launchIntent = Intent(context, DeveloperSettingsActivity::class.java)
|
val launchIntent = Intent(context, DeveloperSettingsActivity::class.java)
|
||||||
context.startActivity(launchIntent)
|
context.startActivity(launchIntent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DeveloperModeDialog(onDismiss: () -> Unit, onEnable: () -> Unit) {
|
||||||
|
AlertDialog(
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_warning),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
title = { Text(stringResource(R.string.preference_developer_options_dialog_title)) },
|
||||||
|
text = { Text(stringResource(R.string.preference_developer_mode_about)) },
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.message_box_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onEnable) {
|
||||||
|
Text(stringResource(R.string.message_box_accept))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismissRequest = onDismiss
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GeneralSettingsGroup() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
OptionsGroup(stringResource(R.string.preference_category_testing)) {
|
||||||
|
SettingsSelectCard(
|
||||||
|
title = stringResource(R.string.preference_theme_name),
|
||||||
|
dialogTitle = stringResource(R.string.preference_theme_select),
|
||||||
|
preferenceKey = PreferenceUtils.Key.THEME,
|
||||||
|
options = linkedMapOf(
|
||||||
|
0 to stringResource(R.string.preference_theme_default),
|
||||||
|
1 to stringResource(R.string.preference_theme_light),
|
||||||
|
2 to stringResource(R.string.preference_theme_dark),
|
||||||
|
),
|
||||||
|
extraSelectBehavior = { updateTheme(context, it) }
|
||||||
|
)
|
||||||
|
SettingsCard(
|
||||||
|
title = stringResource(R.string.preference_black_background_name),
|
||||||
|
preferenceKey = PreferenceUtils.Key.BLACK_BACKGROUND
|
||||||
|
)
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
SettingsCard(
|
||||||
|
title = stringResource(R.string.preference_disable_styles),
|
||||||
|
preferenceKey = PreferenceUtils.Key.DISABLE_USER_THEME
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val currentIcon by PreferenceUtils.useStringPreference(PreferenceUtils.Key.SELECTED_ICON)
|
||||||
|
val iconName = remember(currentIcon) {
|
||||||
|
IconUtils.getIconDetails(
|
||||||
|
ApplicationIcon.fromId(currentIcon ?: "default")
|
||||||
|
).nameId
|
||||||
|
}
|
||||||
|
|
||||||
|
OptionsButton(
|
||||||
|
title = stringResource(R.string.preference_set_application_icon),
|
||||||
|
onClick = {
|
||||||
|
val launchIntent = Intent(context, ApplicationIconActivity::class.java)
|
||||||
|
context.startActivity(launchIntent)
|
||||||
|
},
|
||||||
|
description = stringResource(iconName),
|
||||||
|
displayInline = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
OptionsButton(
|
||||||
|
title = stringResource(R.string.preferences_open_file_manager),
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_folder),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { onOpenFileManager(context) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GameplaySettingsGroup() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
OptionsGroup(stringResource(R.string.preference_category_gameplay)) {
|
||||||
|
SettingsCard(
|
||||||
|
title = stringResource(R.string.preference_load_automatically_name),
|
||||||
|
description = stringResource(R.string.preference_load_automatically_description),
|
||||||
|
preferenceKey = PreferenceUtils.Key.LOAD_AUTOMATICALLY,
|
||||||
|
)
|
||||||
|
SettingsSelectCard(
|
||||||
|
title = stringResource(R.string.preference_display_mode_name),
|
||||||
|
dialogTitle = stringResource(R.string.preference_display_mode_select),
|
||||||
|
preferenceKey = PreferenceUtils.Key.DISPLAY_MODE,
|
||||||
|
options = buildMap {
|
||||||
|
put(0, stringResource(R.string.preference_display_mode_default))
|
||||||
|
put(1, stringResource(R.string.preference_display_mode_legacy))
|
||||||
|
|
||||||
|
// necessary api doesn't exist on older versions of android
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
put(2, stringResource(R.string.preference_display_mode_fullscreen))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
val currentDisplayMode by PreferenceUtils.useIntPreference(PreferenceUtils.Key.DISPLAY_MODE)
|
||||||
|
|
||||||
|
if (currentDisplayMode == 1) {
|
||||||
|
SettingsStringSelectCard(
|
||||||
|
title = stringResource(R.string.preference_custom_aspect_ratio_name),
|
||||||
|
description = stringResource(R.string.preference_custom_aspect_ratio_description),
|
||||||
|
dialogTitle = stringResource(R.string.preference_custom_aspect_ratio_select),
|
||||||
|
preferenceKey = PreferenceUtils.Key.CUSTOM_ASPECT_RATIO,
|
||||||
|
options = linkedMapOf(
|
||||||
|
"16_9" to stringResource(R.string.preference_custom_aspect_ratio_16x9),
|
||||||
|
"16_10" to stringResource(R.string.preference_custom_aspect_ratio_16x10),
|
||||||
|
"4_3" to stringResource(R.string.preference_custom_aspect_ratio_4x3),
|
||||||
|
"1_1" to stringResource(R.string.preference_custom_aspect_ratio_1x1),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsRangeCard(
|
||||||
|
title = stringResource(R.string.preference_screen_zoom_name),
|
||||||
|
description = stringResource(R.string.preference_screen_zoom_description),
|
||||||
|
dialogTitle = stringResource(R.string.preference_screen_zoom_select),
|
||||||
|
preferenceKey = PreferenceUtils.Key.SCREEN_ZOOM,
|
||||||
|
labelSuffix = "x",
|
||||||
|
range = 25..100,
|
||||||
|
scale = 100,
|
||||||
|
step = 5,
|
||||||
|
) {
|
||||||
|
SettingsCard(
|
||||||
|
title = stringResource(R.string.preference_screen_zoom_fit_name),
|
||||||
|
preferenceKey = PreferenceUtils.Key.SCREEN_ZOOM_FIT,
|
||||||
|
asCard = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val maxFrameRate = remember {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
context.display.supportedModes.maxOf { it.refreshRate }
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
(context.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay.refreshRate
|
||||||
|
}.roundToInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsFPSCard(
|
||||||
|
title = stringResource(R.string.preference_limit_framerate_name),
|
||||||
|
dialogTitle = stringResource(R.string.preference_limit_framerate_select),
|
||||||
|
preferenceKey = PreferenceUtils.Key.LIMIT_FRAME_RATE,
|
||||||
|
maxFrameRate = maxFrameRate
|
||||||
|
)
|
||||||
|
|
||||||
|
var showSafeModeDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
OptionsButton(
|
||||||
|
title = stringResource(R.string.preference_launch_safe_mode),
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_shield),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
showSafeModeDialog = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showSafeModeDialog) {
|
||||||
|
SafeModeDialog(onDismiss = {
|
||||||
|
showSafeModeDialog = false
|
||||||
|
}) {
|
||||||
|
val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)!!.apply {
|
||||||
|
// hardcode it, why not
|
||||||
|
action = Intent.ACTION_VIEW
|
||||||
|
data = "geode-launcher://main/launch?safe-mode=true".toUri()
|
||||||
|
}
|
||||||
|
context.startActivity(launchIntent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UpdateSettingsGroup(releaseViewModel: ReleaseViewModel, snackbarHostState: SnackbarHostState, onUpdate: () -> Unit) {
|
||||||
|
if (BuildConfig.GOOGLE_PLAY_BUILD) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val updateStatus by releaseViewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
OptionsGroup(stringResource(R.string.preference_category_updater)) {
|
||||||
|
SettingsCard(
|
||||||
|
title = stringResource(R.string.preference_update_automatically_name),
|
||||||
|
preferenceKey = PreferenceUtils.Key.UPDATE_AUTOMATICALLY,
|
||||||
|
)
|
||||||
|
|
||||||
|
val lastUpdateCheck by PreferenceUtils.useLongPreference(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME)
|
||||||
|
val checkAgoString = remember(lastUpdateCheck) {
|
||||||
|
if (lastUpdateCheck != 0L) {
|
||||||
|
val now = Clock.System.now().toEpochMilliseconds()
|
||||||
|
DateUtils.getRelativeTimeSpanString(
|
||||||
|
lastUpdateCheck,
|
||||||
|
now,
|
||||||
|
DateUtils.MINUTE_IN_MILLIS,
|
||||||
|
DateUtils.FORMAT_ABBREV_RELATIVE
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
"never"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OptionsCard(
|
||||||
|
title = {
|
||||||
|
OptionsTitle(
|
||||||
|
title = stringResource(R.string.preference_check_for_updates_button),
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_update),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
description = stringResource(
|
||||||
|
R.string.preference_check_for_updates_description,
|
||||||
|
checkAgoString
|
||||||
|
)
|
||||||
|
.takeIf { lastUpdateCheck != 0L }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable(
|
||||||
|
onClick = {
|
||||||
|
onUpdate()
|
||||||
|
},
|
||||||
|
role = Role.Button
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
UpdateIndicator(snackbarHostState, updateStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AdvancedSettingsGroup(developerModeEnabled: Boolean, snackbarHostState: SnackbarHostState) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
OptionsGroup(stringResource(R.string.preference_category_developer)) {
|
||||||
|
if (developerModeEnabled) {
|
||||||
|
OptionsButton(
|
||||||
|
title = stringResource(R.string.preference_open_developer_options),
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_data_object),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { onOpenDeveloperOptions(context) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
OptionsButton(
|
||||||
|
title = stringResource(R.string.preferences_view_geode_logs),
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_description),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
val launchIntent = Intent(context, GeodeLogsListingActivity::class.java)
|
||||||
|
context.startActivity(launchIntent)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
OptionsButton(
|
||||||
|
title = stringResource(R.string.preferences_view_crashes),
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_bug_report),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
val launchIntent = Intent(context, CrashLogsActivity::class.java)
|
||||||
|
context.startActivity(launchIntent)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
BackupButton(snackbarHostState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AboutSettingsGroup(developerModeEnabled: Boolean) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val resources = LocalResources.current
|
||||||
|
val currentRelease by PreferenceUtils.useStringPreference(PreferenceUtils.Key.CURRENT_VERSION_TAG)
|
||||||
|
|
||||||
|
OptionsGroup(stringResource(R.string.preference_category_about)) {
|
||||||
|
var launcherButtonTapped by remember { mutableIntStateOf(0) }
|
||||||
|
var showDeveloperDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
OptionsButton(
|
||||||
|
stringResource(R.string.preference_launcher_version_name),
|
||||||
|
stringResource(
|
||||||
|
R.string.preference_launcher_version_description,
|
||||||
|
BuildConfig.VERSION_NAME
|
||||||
|
),
|
||||||
|
displayInline = true
|
||||||
|
) {
|
||||||
|
launcherButtonTapped += 1
|
||||||
|
|
||||||
|
if (launcherButtonTapped >= 7) {
|
||||||
|
if (developerModeEnabled) {
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
R.string.preference_enable_developer_again,
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
)
|
||||||
|
.show()
|
||||||
|
} else {
|
||||||
|
showDeveloperDialog = true
|
||||||
|
}
|
||||||
|
launcherButtonTapped = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDeveloperDialog) {
|
||||||
|
DeveloperModeDialog(
|
||||||
|
onDismiss = {
|
||||||
|
showDeveloperDialog = false
|
||||||
|
},
|
||||||
|
onEnable = {
|
||||||
|
PreferenceUtils.get(context)
|
||||||
|
.setBoolean(PreferenceUtils.Key.DEVELOPER_MODE, true)
|
||||||
|
showDeveloperDialog = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("SENSELESS_COMPARISON")
|
||||||
|
OptionsLabel(
|
||||||
|
stringResource(R.string.preference_loader_version_name),
|
||||||
|
stringResource(
|
||||||
|
R.string.preference_loader_version_description,
|
||||||
|
if (BuildConfig.PREBUNDLED_GEODE != null)
|
||||||
|
"${BuildConfig.PREBUNDLED_GEODE}*"
|
||||||
|
else (currentRelease ?: "unknown")
|
||||||
|
),
|
||||||
|
displayInline = true
|
||||||
|
)
|
||||||
|
|
||||||
|
val gameInstalled = remember {
|
||||||
|
GamePackageUtils.isGameInstalled(context.packageManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gameInstalled) {
|
||||||
|
val gameVersion = remember {
|
||||||
|
GamePackageUtils.getUnifiedVersionName(context.packageManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
val gameSource = remember {
|
||||||
|
resources.getString(
|
||||||
|
when (GamePackageUtils.identifyGameSource(context.packageManager)) {
|
||||||
|
GamePackageUtils.GameSource.GOOGLE -> R.string.preference_game_source_google
|
||||||
|
GamePackageUtils.GameSource.AMAZON -> R.string.preference_game_source_amazon
|
||||||
|
else -> R.string.preference_game_source_unknown
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
OptionsLabel(
|
||||||
|
stringResource(R.string.preference_game_version_name),
|
||||||
|
stringResource(
|
||||||
|
R.string.preference_game_version_description,
|
||||||
|
gameVersion,
|
||||||
|
gameSource
|
||||||
|
),
|
||||||
|
displayInline = true
|
||||||
|
)
|
||||||
|
|
||||||
|
OptionsLabel(
|
||||||
|
stringResource(R.string.preference_architecture_name),
|
||||||
|
stringResource(
|
||||||
|
R.string.preference_architecture_description,
|
||||||
|
Build.MODEL,
|
||||||
|
LaunchUtils.applicationArchitecture
|
||||||
|
),
|
||||||
|
displayInline = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
onBackPressedDispatcher: OnBackPressedDispatcher?,
|
onBackPressedDispatcher: OnBackPressedDispatcher?,
|
||||||
releaseViewModel: ReleaseViewModel = viewModel(factory = ReleaseViewModel.Factory)
|
releaseViewModel: ReleaseViewModel = viewModel(factory = ReleaseViewModel.Factory)
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val resources = LocalResources.current
|
||||||
|
|
||||||
val currentRelease by PreferenceUtils.useStringPreference(PreferenceUtils.Key.CURRENT_VERSION_TAG)
|
|
||||||
val updateStatus by releaseViewModel.uiState.collectAsState()
|
|
||||||
|
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
var showUpdateInProgress by remember { mutableStateOf(false) }
|
var showUpdateInProgress by remember { mutableStateOf(false) }
|
||||||
@@ -254,7 +732,7 @@ fun SettingsScreen(
|
|||||||
if (showUpdateInProgress) {
|
if (showUpdateInProgress) {
|
||||||
LaunchedEffect(snackbarHostState) {
|
LaunchedEffect(snackbarHostState) {
|
||||||
snackbarHostState.showSnackbar(
|
snackbarHostState.showSnackbar(
|
||||||
context.getString(R.string.preference_check_for_updates_already_updating)
|
resources.getString(R.string.preference_check_for_updates_already_updating)
|
||||||
)
|
)
|
||||||
showUpdateInProgress = false
|
showUpdateInProgress = false
|
||||||
}
|
}
|
||||||
@@ -279,19 +757,20 @@ fun SettingsScreen(
|
|||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
|
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
painterResource(R.drawable.icon_arrow_back),
|
||||||
contentDescription = context.getString(R.string.back_icon_alt)
|
contentDescription = stringResource(R.string.back_icon_alt)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
context.getString(R.string.title_activity_settings),
|
stringResource(R.string.title_activity_settings),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(MaterialTheme.colorScheme.background),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
content = { innerPadding ->
|
content = { innerPadding ->
|
||||||
@@ -302,105 +781,23 @@ fun SettingsScreen(
|
|||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
OptionsGroup(context.getString(R.string.preference_category_testing)) {
|
GeneralSettingsGroup()
|
||||||
SettingsSelectCard(
|
GameplaySettingsGroup()
|
||||||
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
|
|
||||||
)
|
|
||||||
OptionsButton(
|
|
||||||
title = stringResource(R.string.preferences_open_file_manager),
|
|
||||||
onClick = { onOpenFileManager(context) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
OptionsGroup(stringResource(R.string.preference_category_gameplay)) {
|
UpdateSettingsGroup(releaseViewModel, snackbarHostState, onUpdate = {
|
||||||
SettingsCard(
|
|
||||||
title = context.getString(R.string.preference_load_automatically_name),
|
|
||||||
description = context.getString(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),
|
|
||||||
// necessary api doesn't exist on older versions of android
|
|
||||||
maxVal = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) 2 else 1,
|
|
||||||
preferenceKey = PreferenceUtils.Key.DISPLAY_MODE,
|
|
||||||
toLabel = { displayOptionToKey(it) }
|
|
||||||
)
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
||||||
SettingsCard(
|
|
||||||
title = stringResource(R.string.preference_force_hrr),
|
|
||||||
preferenceKey = PreferenceUtils.Key.FORCE_HRR,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
OptionsGroup(context.getString(R.string.preference_category_updater)) {
|
|
||||||
SettingsCard(
|
|
||||||
title = context.getString(R.string.preference_update_automatically_name),
|
|
||||||
preferenceKey = PreferenceUtils.Key.UPDATE_AUTOMATICALLY,
|
|
||||||
)
|
|
||||||
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) {
|
if (releaseViewModel.isInUpdate) {
|
||||||
showUpdateInProgress = true
|
showUpdateInProgress = true
|
||||||
} else {
|
} else {
|
||||||
releaseViewModel.runReleaseCheck(true)
|
releaseViewModel.runReleaseCheck(true)
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
role = Role.Button
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
UpdateIndicator(snackbarHostState, updateStatus)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
OptionsGroup(stringResource(R.string.preference_category_developer)) {
|
val developerModeEnabled by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DEVELOPER_MODE)
|
||||||
OptionsButton(
|
|
||||||
title = stringResource(R.string.preferences_view_logs),
|
|
||||||
icon = {
|
|
||||||
Icon(painterResource(R.drawable.icon_description), contentDescription = null)
|
|
||||||
},
|
|
||||||
onClick = { onOpenLogs(context) }
|
|
||||||
)
|
|
||||||
OptionsButton(
|
|
||||||
title = stringResource(R.string.preference_open_developer_options),
|
|
||||||
icon = {
|
|
||||||
Icon(painterResource(R.drawable.icon_data_object), contentDescription = null)
|
|
||||||
},
|
|
||||||
onClick = { onOpenDeveloperOptions(context) }
|
|
||||||
)
|
|
||||||
OptionsButton(
|
|
||||||
title = context.getString(R.string.preferences_copy_external_button),
|
|
||||||
description = LaunchUtils.getBaseDirectory(context).path,
|
|
||||||
onClick = { onOpenFolder(context) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(
|
AdvancedSettingsGroup(developerModeEnabled, snackbarHostState)
|
||||||
stringResource(R.string.preference_launcher_version, BuildConfig.VERSION_NAME),
|
AboutSettingsGroup(developerModeEnabled)
|
||||||
style = Typography.labelSmall,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
|
Spacer(Modifier.height(4.dp))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,380 +0,0 @@
|
|||||||
package com.geode.launcher.preferences
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
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.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.selection.toggleable
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Clear
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
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.Switch
|
|
||||||
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.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.semantics.Role
|
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.window.Dialog
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
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 = 0..maxVal
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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 StringDialog(
|
|
||||||
title: String,
|
|
||||||
onDismissRequest: () -> Unit,
|
|
||||||
onSelect: (String) -> Unit,
|
|
||||||
initialValue: String,
|
|
||||||
filterInput: ((String) -> String)? = null
|
|
||||||
) {
|
|
||||||
var enteredValue by remember { mutableStateOf(initialValue) }
|
|
||||||
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { onDismissRequest() },
|
|
||||||
title = {
|
|
||||||
Text(title)
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = enteredValue,
|
|
||||||
onValueChange = {
|
|
||||||
enteredValue = filterInput?.invoke(it) ?: it
|
|
||||||
},
|
|
||||||
singleLine = true,
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
keyboardType = KeyboardType.Ascii,
|
|
||||||
autoCorrect = false
|
|
||||||
),
|
|
||||||
trailingIcon = {
|
|
||||||
if (enteredValue.isNotEmpty()) {
|
|
||||||
IconButton(onClick = { enteredValue = "" }) {
|
|
||||||
Icon(
|
|
||||||
Icons.Filled.Clear,
|
|
||||||
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 <T> SelectDialog(
|
|
||||||
title: String,
|
|
||||||
onDismissRequest: () -> Unit,
|
|
||||||
onSelect: (T) -> Unit,
|
|
||||||
initialValue: T,
|
|
||||||
toLabel: @Composable (T) -> String,
|
|
||||||
optionsCount: Iterable<T>,
|
|
||||||
) {
|
|
||||||
var selectedValue by remember { mutableStateOf(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
|
|
||||||
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, icon: (@Composable () -> Unit)? = null, onClick: () -> Unit) {
|
|
||||||
OptionsCard(
|
|
||||||
title = {
|
|
||||||
OptionsTitle(
|
|
||||||
title = title,
|
|
||||||
description = description,
|
|
||||||
icon = icon
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.clickable(onClick = onClick, role = Role.Button)
|
|
||||||
) { }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SettingsCard(title: String, description: String? = null, icon: (@Composable () -> Unit)? = 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,
|
|
||||||
icon = icon
|
|
||||||
)
|
|
||||||
},
|
|
||||||
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, icon: (@Composable () -> Unit)? = null) {
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
if (icon != null) {
|
|
||||||
icon()
|
|
||||||
}
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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,218 @@
|
|||||||
|
package com.geode.launcher.preferences
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.OnBackPressedDispatcher
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.geode.launcher.R
|
||||||
|
import com.geode.launcher.UserDirectoryProvider
|
||||||
|
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||||
|
import com.geode.launcher.ui.theme.LocalTheme
|
||||||
|
import com.geode.launcher.ui.theme.Theme
|
||||||
|
import com.geode.launcher.ui.theme.Typography
|
||||||
|
import com.geode.launcher.ui.theme.robotoMonoFamily
|
||||||
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
|
||||||
|
class TextViewActivity : ComponentActivity() {
|
||||||
|
companion object {
|
||||||
|
const val EXTRA_LOG_VIEW_FILENAME = "EXTRA_LOG_FILENAME"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
val filename = intent.getStringExtra(EXTRA_LOG_VIEW_FILENAME)
|
||||||
|
if (filename.isNullOrEmpty()) {
|
||||||
|
val launchIntent = Intent(this, GeodeLogsListingActivity::class.java)
|
||||||
|
startActivity(launchIntent)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
|
||||||
|
val theme = Theme.fromInt(themeOption)
|
||||||
|
|
||||||
|
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
|
||||||
|
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalTheme provides theme) {
|
||||||
|
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
GeodeLogView(onBackPressedDispatcher, filename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onShareFile(context: Context, filename: File) {
|
||||||
|
val baseDirectory = LaunchUtils.getBaseDirectory(context, true)
|
||||||
|
|
||||||
|
val providerLocation = filename.toRelativeString(baseDirectory)
|
||||||
|
val documentsPath = "${UserDirectoryProvider.ROOT}${providerLocation}"
|
||||||
|
|
||||||
|
val uri = DocumentsContract.buildDocumentUri("${context.packageName}.user", documentsPath)
|
||||||
|
|
||||||
|
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||||
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
|
type = "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
context.startActivity(shareIntent)
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
Toast.makeText(context, R.string.no_activity_found, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun GeodeLogView(
|
||||||
|
onBackPressedDispatcher: OnBackPressedDispatcher?,
|
||||||
|
filename: String,
|
||||||
|
) {
|
||||||
|
var loadingFile by remember { mutableStateOf(true) }
|
||||||
|
var description by remember { mutableStateOf(emptyList<String>()) }
|
||||||
|
|
||||||
|
val toRead = File(filename)
|
||||||
|
|
||||||
|
LaunchedEffect(filename) {
|
||||||
|
loadingFile = true
|
||||||
|
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
if (!toRead.isFile) {
|
||||||
|
description = listOf("File does not exist!")
|
||||||
|
} else {
|
||||||
|
description = toRead.readLines()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingFile = false
|
||||||
|
}
|
||||||
|
|
||||||
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
|
topBar = {
|
||||||
|
Column {
|
||||||
|
TopAppBar(
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_arrow_back),
|
||||||
|
contentDescription = stringResource(R.string.back_icon_alt)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
toRead.name,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
},
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
actions = {
|
||||||
|
val context = LocalContext.current
|
||||||
|
IconButton(onClick = { onShareFile(context, toRead) }) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_share),
|
||||||
|
contentDescription = stringResource(R.string.application_logs_share)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (loadingFile) {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
if (!loadingFile) {
|
||||||
|
if (description.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.application_geode_logs_empty),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
style = Typography.bodyLarge,
|
||||||
|
modifier = Modifier.fillMaxSize().padding(8.dp)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
SelectionContainer {
|
||||||
|
LazyColumn(modifier = Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.padding(horizontal = 8.dp)
|
||||||
|
) {
|
||||||
|
|
||||||
|
items(description) { line ->
|
||||||
|
Text(
|
||||||
|
line,
|
||||||
|
fontFamily = robotoMonoFamily,
|
||||||
|
style = Typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package com.geode.launcher.preferences.components
|
||||||
|
|
||||||
|
import androidx.activity.OnBackPressedDispatcher
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.geode.launcher.R
|
||||||
|
import com.geode.launcher.log.BaseDirectoryViewModel
|
||||||
|
import com.geode.launcher.ui.theme.Typography
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CrashLogsTopBar(
|
||||||
|
onBackPressedDispatcher: OnBackPressedDispatcher?,
|
||||||
|
directoryViewModel: BaseDirectoryViewModel<*>,
|
||||||
|
scrollBehavior: TopAppBarScrollBehavior,
|
||||||
|
@StringRes titleId: Int,
|
||||||
|
additionalOptions: (@Composable (() -> Unit) -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val isLoading by directoryViewModel.isLoading.collectAsState()
|
||||||
|
var showMoreOptions by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Column {
|
||||||
|
TopAppBar(
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_arrow_back),
|
||||||
|
contentDescription = stringResource(R.string.back_icon_alt)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { showMoreOptions = !showMoreOptions }) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_more_vert),
|
||||||
|
contentDescription = stringResource(R.string.application_logs_more)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showMoreOptions,
|
||||||
|
onDismissRequest = { showMoreOptions = false }
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_delete),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(stringResource(R.string.application_logs_delete))
|
||||||
|
}, onClick = {
|
||||||
|
directoryViewModel.clearAllFiles()
|
||||||
|
showMoreOptions = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
additionalOptions?.invoke {
|
||||||
|
showMoreOptions = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
stringResource(titleId),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
},
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun <T> CrashLogsListing(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
directoryViewModel: BaseDirectoryViewModel<T>,
|
||||||
|
@StringRes noneAvailableId: Int,
|
||||||
|
logCard: @Composable (T, Shape) -> Unit,
|
||||||
|
) {
|
||||||
|
val logLines = directoryViewModel.lineState
|
||||||
|
val isLoading by directoryViewModel.isLoading.collectAsState()
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier,
|
||||||
|
verticalArrangement = Arrangement.SpaceEvenly,
|
||||||
|
) {
|
||||||
|
if (!isLoading && logLines.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
stringResource(noneAvailableId),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
style = Typography.bodyLarge,
|
||||||
|
modifier = Modifier.fillMaxSize().padding(8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val itemsSize = logLines.size
|
||||||
|
|
||||||
|
itemsIndexed(
|
||||||
|
logLines,
|
||||||
|
key = { index, line -> directoryViewModel.getItemKey(line) }
|
||||||
|
) { index, line ->
|
||||||
|
val shape = RoundedCornerShape(
|
||||||
|
topStart = if (index == 0) 16.dp else 4.dp,
|
||||||
|
topEnd = if (index == 0) 16.dp else 4.dp,
|
||||||
|
bottomStart = if (index + 1 == itemsSize) 16.dp else 4.dp,
|
||||||
|
bottomEnd = if (index + 1 == itemsSize) 16.dp else 4.dp
|
||||||
|
)
|
||||||
|
|
||||||
|
logCard(line, shape)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun <T> DirectoryListingScreen(
|
||||||
|
onBackPressedDispatcher: OnBackPressedDispatcher?,
|
||||||
|
directoryViewModel: BaseDirectoryViewModel<T>,
|
||||||
|
@StringRes titleId: Int,
|
||||||
|
@StringRes noneAvailableId: Int,
|
||||||
|
additionalOptions: (@Composable (() -> Unit) -> Unit)? = null,
|
||||||
|
logCard: @Composable (T, Shape) -> Unit,
|
||||||
|
) {
|
||||||
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
|
topBar = {
|
||||||
|
CrashLogsTopBar(
|
||||||
|
onBackPressedDispatcher,
|
||||||
|
directoryViewModel,
|
||||||
|
scrollBehavior,
|
||||||
|
titleId,
|
||||||
|
additionalOptions = additionalOptions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
CrashLogsListing(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
directoryViewModel,
|
||||||
|
noneAvailableId = noneAvailableId,
|
||||||
|
logCard = logCard
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
package com.geode.launcher.preferences.components
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import android.util.Xml
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import com.geode.launcher.R
|
||||||
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.FormBody
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.coroutines.executeAsync
|
||||||
|
import okio.Buffer
|
||||||
|
import okio.ByteString.Companion.decodeBase64
|
||||||
|
import okio.GzipSource
|
||||||
|
import okio.buffer
|
||||||
|
import okio.source
|
||||||
|
import org.xmlpull.v1.XmlPullParser
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.experimental.xor
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
const val TAG = "com.geode.launcher.SaveBackup"
|
||||||
|
|
||||||
|
data class UserDetails(
|
||||||
|
val userName: String,
|
||||||
|
val gjp2: String,
|
||||||
|
val accountId: Int,
|
||||||
|
val uuid: Int,
|
||||||
|
val udid: String,
|
||||||
|
val binaryVersion: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class BackupParseFailure {
|
||||||
|
SAVE_MISSING,
|
||||||
|
DECODE_FAILED,
|
||||||
|
DETAILS_MISSING,
|
||||||
|
SAVE_TOO_LARGE
|
||||||
|
}
|
||||||
|
|
||||||
|
class BackupParseException(val failureCode: BackupParseFailure) : Exception("parse error: $failureCode")
|
||||||
|
|
||||||
|
enum class BackupUploadFailure {
|
||||||
|
GENERIC_ERROR,
|
||||||
|
NETWORK_ERROR,
|
||||||
|
INVALID_LOGIN
|
||||||
|
}
|
||||||
|
|
||||||
|
class BackupUploadException(val failureCode: BackupUploadFailure, val gjCode: Int = -1) : Exception("upload error: $failureCode")
|
||||||
|
|
||||||
|
suspend fun getAccountBackupUrl(backupClient: OkHttpClient, details: UserDetails): Result<HttpUrl> {
|
||||||
|
return try {
|
||||||
|
// accountID=%i&type=1&secret=Wmfd2893gb7
|
||||||
|
val body = FormBody.Builder()
|
||||||
|
.add("secret", "Wmfd2893gb7")
|
||||||
|
.add("type", "1")
|
||||||
|
.add("accountID", details.accountId.toString())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("https://www.boomlings.com/database/getAccountURL.php")
|
||||||
|
.addHeader("User-Agent", "")
|
||||||
|
.post(body)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val call = backupClient.newCall(request)
|
||||||
|
call.executeAsync()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "getAccountBackupUrl failed: ${e.stackTraceToString()}")
|
||||||
|
return Result.failure(BackupUploadException(BackupUploadFailure.NETWORK_ERROR))
|
||||||
|
}.use { response ->
|
||||||
|
if (response.code != 200) {
|
||||||
|
return Result.failure(BackupUploadException(BackupUploadFailure.GENERIC_ERROR))
|
||||||
|
}
|
||||||
|
|
||||||
|
val url = response.body.string().toHttpUrlOrNull()
|
||||||
|
?: return Result.failure(BackupUploadException(BackupUploadFailure.GENERIC_ERROR))
|
||||||
|
|
||||||
|
Result.success(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decryptFileToBuffer(file: File, buffer: Buffer) = file.source().buffer()
|
||||||
|
.use { baseBuffer ->
|
||||||
|
val key = 0xb.toByte()
|
||||||
|
val nullByte = 0x0.toByte()
|
||||||
|
|
||||||
|
while (!baseBuffer.exhausted()) {
|
||||||
|
val b = baseBuffer.readByte()
|
||||||
|
val d = b.xor(key)
|
||||||
|
|
||||||
|
// gd sometimes encodes garbage after null byte, treat it like a c string
|
||||||
|
if (d == nullByte)
|
||||||
|
break
|
||||||
|
|
||||||
|
buffer.writeByte(d.toInt())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun generateSaveString(gameManager: File, localLevels: File): String = Buffer()
|
||||||
|
.use { saveBuffer ->
|
||||||
|
decryptFileToBuffer(gameManager, saveBuffer)
|
||||||
|
saveBuffer.writeByte(';'.code)
|
||||||
|
decryptFileToBuffer(localLevels, saveBuffer)
|
||||||
|
|
||||||
|
saveBuffer.readUtf8()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun generateBackupRequest(details: UserDetails, url: HttpUrl, gameManager: File, localLevels: File): Request {
|
||||||
|
val saveData = generateSaveString(gameManager, localLevels)
|
||||||
|
|
||||||
|
// %s/database/accounts/backupGJAccountNew.php
|
||||||
|
val backupUrl = url.newBuilder()
|
||||||
|
.addPathSegments("database/accounts/backupGJAccountNew.php")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// gameVersion=22&binaryVersion=%i&dvs=2&udid=%s&uuid=%i&accountID=%i&gjp2=%s&secret=Wmfv3899gc9&saveData=%s
|
||||||
|
val body = FormBody.Builder()
|
||||||
|
.add("gameVersion", "22")
|
||||||
|
.add("binaryVersion", details.binaryVersion.toString())
|
||||||
|
.add("dvs", "2")
|
||||||
|
.add("udid", details.udid)
|
||||||
|
.add("uuid", details.uuid.toString())
|
||||||
|
.add("accountID", details.accountId.toString())
|
||||||
|
.add("secret", "Wmfv3899gc9")
|
||||||
|
.add("gjp2", details.gjp2)
|
||||||
|
.add("saveData", saveData)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return Request.Builder()
|
||||||
|
.url(backupUrl)
|
||||||
|
.addHeader("User-Agent", "")
|
||||||
|
.post(body)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun performBackupRequest(backupClient: OkHttpClient, details: UserDetails, url: HttpUrl, gameManager: File, localLevels: File): Result<Unit> {
|
||||||
|
return try {
|
||||||
|
// we split this into multiple functions to free up as many references as we can
|
||||||
|
// go little gc go
|
||||||
|
backupClient.newCall(
|
||||||
|
generateBackupRequest(details, url, gameManager, localLevels)
|
||||||
|
).executeAsync()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "performBackupRequest failed: ${e.stackTraceToString()}")
|
||||||
|
return Result.failure(BackupUploadException(BackupUploadFailure.GENERIC_ERROR))
|
||||||
|
}.use { response ->
|
||||||
|
if (response.code != 200) {
|
||||||
|
return Result.failure(BackupUploadException(BackupUploadFailure.GENERIC_ERROR))
|
||||||
|
}
|
||||||
|
|
||||||
|
val responseBody = response.body.string().toIntOrNull()
|
||||||
|
?: return Result.failure(BackupUploadException(BackupUploadFailure.GENERIC_ERROR))
|
||||||
|
|
||||||
|
when (responseBody) {
|
||||||
|
1 -> Result.success(Unit)
|
||||||
|
-5 -> Result.failure(BackupUploadException(BackupUploadFailure.INVALID_LOGIN, responseBody))
|
||||||
|
else -> Result.failure(BackupUploadException(BackupUploadFailure.GENERIC_ERROR, responseBody))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun skip(parser: XmlPullParser) {
|
||||||
|
if (parser.eventType != XmlPullParser.START_TAG) {
|
||||||
|
throw IllegalStateException()
|
||||||
|
}
|
||||||
|
var depth = 1
|
||||||
|
while (depth != 0) {
|
||||||
|
when (parser.next()) {
|
||||||
|
XmlPullParser.END_TAG -> depth--
|
||||||
|
XmlPullParser.START_TAG -> depth++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getNextValue(parser: XmlPullParser): String? {
|
||||||
|
while (parser.eventType != XmlPullParser.START_TAG && parser.eventType != XmlPullParser.END_DOCUMENT) {
|
||||||
|
parser.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parser.eventType == XmlPullParser.START_TAG) {
|
||||||
|
// we could support t/f here but we also don't have to
|
||||||
|
if (parser.name != "s" && parser.name != "i") {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return parser.nextText()
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseSaveFile(parser: XmlPullParser): Result<UserDetails> = try {
|
||||||
|
var userName: String? = null
|
||||||
|
var gjp2: String? = null
|
||||||
|
var accountId: Int? = null
|
||||||
|
var uuid: Int? = null
|
||||||
|
var udid: String? = null
|
||||||
|
var binaryVersion: Int? = null
|
||||||
|
|
||||||
|
var inPlist = false
|
||||||
|
var inDict = false
|
||||||
|
|
||||||
|
var eventType = parser.eventType
|
||||||
|
while (eventType != XmlPullParser.END_DOCUMENT) {
|
||||||
|
when (eventType) {
|
||||||
|
XmlPullParser.START_TAG -> {
|
||||||
|
if (inDict) {
|
||||||
|
// ignore inner elements, just in case they conflict
|
||||||
|
if (parser.name != "k") {
|
||||||
|
skip(parser)
|
||||||
|
} else {
|
||||||
|
val keyName = parser.nextText()
|
||||||
|
when (keyName) {
|
||||||
|
"GJA_001" -> userName = getNextValue(parser)
|
||||||
|
"GJA_003" -> accountId = getNextValue(parser)?.toIntOrNull()
|
||||||
|
"GJA_005" -> gjp2 = getNextValue(parser)
|
||||||
|
"playerUserID" -> uuid = getNextValue(parser)?.toIntOrNull()
|
||||||
|
"playerUDID" -> udid = getNextValue(parser)
|
||||||
|
"binaryVersion" -> binaryVersion = getNextValue(parser)?.toIntOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// traverse to initial plist -> dict element
|
||||||
|
if (!inPlist && parser.name == "plist") {
|
||||||
|
inPlist = true
|
||||||
|
}
|
||||||
|
if (!inDict && parser.name == "dict") {
|
||||||
|
inDict = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eventType = parser.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userName == null || gjp2 == null || accountId == null || uuid == null || udid == null || binaryVersion == null) {
|
||||||
|
Result.failure(BackupParseException(BackupParseFailure.DETAILS_MISSING))
|
||||||
|
} else {
|
||||||
|
Result.success(
|
||||||
|
UserDetails(
|
||||||
|
userName = userName,
|
||||||
|
gjp2 = gjp2,
|
||||||
|
accountId = accountId,
|
||||||
|
uuid = uuid,
|
||||||
|
udid = udid,
|
||||||
|
binaryVersion = binaryVersion
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "parseSaveFile failed: ${e.stackTraceToString()}")
|
||||||
|
Result.failure(BackupParseException(BackupParseFailure.DECODE_FAILED))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decryptFileToString(file: File) = Buffer()
|
||||||
|
.use { decryptedBuffer ->
|
||||||
|
decryptFileToBuffer(file, decryptedBuffer)
|
||||||
|
decryptedBuffer.readUtf8()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getUserDetails(gameManager: File): Result<UserDetails> {
|
||||||
|
return try {
|
||||||
|
val base64Decoded = run {
|
||||||
|
// free decryptString after decoding
|
||||||
|
val decryptString = decryptFileToString(gameManager)
|
||||||
|
decryptString.decodeBase64()
|
||||||
|
} ?: return Result.failure(BackupParseException(BackupParseFailure.DECODE_FAILED))
|
||||||
|
|
||||||
|
// gzipSource will close the underlying buffer when closed
|
||||||
|
GzipSource(
|
||||||
|
Buffer().write(base64Decoded)
|
||||||
|
).buffer().use { gzipSource ->
|
||||||
|
runInterruptible {
|
||||||
|
val parser = Xml.newPullParser()
|
||||||
|
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
|
||||||
|
parser.setInput(gzipSource.inputStream(), null)
|
||||||
|
|
||||||
|
parseSaveFile(parser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "getUserDetails failed: ${e.stackTraceToString()}")
|
||||||
|
return Result.failure(BackupParseException(BackupParseFailure.DECODE_FAILED))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const val MB_TO_BYTES = 1048576 // 2^20
|
||||||
|
|
||||||
|
suspend fun backupSaveToCloud(context: Context): Result<Unit> {
|
||||||
|
val baseDirectory = LaunchUtils.getBaseDirectory(context)
|
||||||
|
|
||||||
|
val gameManager = File(baseDirectory,"/save/CCGameManager.dat")
|
||||||
|
val localLevels = File(baseDirectory, "/save/CCLocalLevels.dat")
|
||||||
|
|
||||||
|
if (!gameManager.isFile || !localLevels.isFile) {
|
||||||
|
return Result.failure(BackupParseException(BackupParseFailure.SAVE_MISSING))
|
||||||
|
}
|
||||||
|
|
||||||
|
// hardcode 32mb max size
|
||||||
|
// this is how the game checks it, which probably isn't the best
|
||||||
|
val totalSize = (gameManager.length() + localLevels.length()) / MB_TO_BYTES.toDouble()
|
||||||
|
println("Backup - calculated size of %.2fmb".format(totalSize))
|
||||||
|
|
||||||
|
if (totalSize > 32.0) {
|
||||||
|
return Result.failure(BackupParseException(BackupParseFailure.SAVE_TOO_LARGE))
|
||||||
|
}
|
||||||
|
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
val userDetails = getUserDetails(gameManager).getOrElse {
|
||||||
|
return@withContext Result.failure(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
println("Backup - performing as ${userDetails.userName}, accountID=${userDetails.accountId}")
|
||||||
|
|
||||||
|
val backupClient = OkHttpClient.Builder()
|
||||||
|
.writeTimeout(500.seconds)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val backupUrl = getAccountBackupUrl(backupClient, userDetails).getOrElse {
|
||||||
|
return@withContext Result.failure(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
performBackupRequest(backupClient, userDetails, backupUrl, gameManager, localLevels).getOrElse {
|
||||||
|
return@withContext Result.failure(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
println("Backup completed!")
|
||||||
|
|
||||||
|
Result.success(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BackupConfirmDialog(onDismiss: () -> Unit, onEnable: () -> Unit) {
|
||||||
|
AlertDialog(
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_cloud_alert),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
title = { Text(stringResource(R.string.preference_backup_confirm_title)) },
|
||||||
|
text = { Text(stringResource(R.string.preference_backup_confirm_description)) },
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.message_box_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onEnable) {
|
||||||
|
Text(stringResource(R.string.message_box_continue))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismissRequest = onDismiss
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun messageFromResult(context: Context, result: Result<Unit>): String {
|
||||||
|
val exception = result.exceptionOrNull()
|
||||||
|
?: return context.getString(R.string.preference_backup_success)
|
||||||
|
|
||||||
|
return when (exception) {
|
||||||
|
is BackupParseException -> when (exception.failureCode) {
|
||||||
|
BackupParseFailure.SAVE_MISSING -> context.getString(R.string.preference_backup_save_missing)
|
||||||
|
BackupParseFailure.DECODE_FAILED -> context.getString(R.string.preference_backup_save_decode_failed)
|
||||||
|
BackupParseFailure.DETAILS_MISSING -> context.getString(R.string.preference_backup_save_details_missing)
|
||||||
|
BackupParseFailure.SAVE_TOO_LARGE -> context.getString(R.string.preference_backup_save_too_large)
|
||||||
|
}
|
||||||
|
is BackupUploadException -> when (exception.failureCode) {
|
||||||
|
BackupUploadFailure.GENERIC_ERROR -> context.getString(R.string.preference_backup_failed, exception.gjCode)
|
||||||
|
BackupUploadFailure.NETWORK_ERROR -> context.getString(R.string.preference_backup_failed_network)
|
||||||
|
BackupUploadFailure.INVALID_LOGIN -> context.getString(R.string.preference_backup_failed_invalid_login)
|
||||||
|
}
|
||||||
|
else -> context.getString(R.string.preference_backup_failed_generic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BackupDialog(onDismiss: () -> Unit, isCancelling: Boolean) {
|
||||||
|
AlertDialog(
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_cloud_upload),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
if (isCancelling) {
|
||||||
|
Text(stringResource(R.string.preference_backup_cancelling))
|
||||||
|
} else {
|
||||||
|
Text(stringResource(R.string.preference_backup_in_progress))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
LinearProgressIndicator()
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss, enabled = !isCancelling) {
|
||||||
|
Text(stringResource(R.string.message_box_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismissRequest = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BackupButton(snackbarHostState: SnackbarHostState) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
var showBackupDialog by remember { mutableStateOf(false) }
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
var backupInProgress by remember { mutableStateOf(false) }
|
||||||
|
var backupJob by remember { mutableStateOf<Job?>(null) }
|
||||||
|
|
||||||
|
OptionsButton(
|
||||||
|
title = stringResource(R.string.preference_backup_cloud),
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_cloud_upload),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
showBackupDialog = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showBackupDialog) {
|
||||||
|
BackupConfirmDialog(onDismiss = {
|
||||||
|
showBackupDialog = false
|
||||||
|
}) {
|
||||||
|
showBackupDialog = false
|
||||||
|
|
||||||
|
backupJob = coroutineScope.launch {
|
||||||
|
backupInProgress = true
|
||||||
|
val responseStatus = backupSaveToCloud(context)
|
||||||
|
backupInProgress = false
|
||||||
|
|
||||||
|
snackbarHostState.showSnackbar(messageFromResult(context, responseStatus))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isCancelling by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
if (backupInProgress) {
|
||||||
|
BackupDialog(onDismiss = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
isCancelling = true
|
||||||
|
|
||||||
|
backupJob?.cancelAndJoin()
|
||||||
|
|
||||||
|
isCancelling = false
|
||||||
|
backupInProgress = false
|
||||||
|
}
|
||||||
|
}, isCancelling)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,607 @@
|
|||||||
|
package com.geode.launcher.preferences.components
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.activity.compose.LocalActivity
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.defaultMinSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.selection.toggleable
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.geode.launcher.R
|
||||||
|
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||||
|
import com.geode.launcher.ui.theme.Typography
|
||||||
|
import com.geode.launcher.utils.LabelledText
|
||||||
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
|
import com.geode.launcher.utils.ProfileManager
|
||||||
|
import kotlin.collections.get
|
||||||
|
import kotlin.math.log10
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
|
|
||||||
|
fun toggleSetting(context: Context, preferenceKey: PreferenceUtils.Key): Boolean {
|
||||||
|
val preferences = PreferenceUtils.get(context)
|
||||||
|
|
||||||
|
return preferences.toggleBoolean(preferenceKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSetting(context: Context, preferenceKey: PreferenceUtils.Key): Boolean {
|
||||||
|
val preferences = PreferenceUtils.get(context)
|
||||||
|
|
||||||
|
return preferences.getBoolean(preferenceKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun OptionsGroup(title: String? = null, content: @Composable () -> Unit) {
|
||||||
|
Column {
|
||||||
|
if (title != null) {
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style = Typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsSelectCard(
|
||||||
|
title: String,
|
||||||
|
dialogTitle: String,
|
||||||
|
preferenceKey: PreferenceUtils.Key,
|
||||||
|
options: Map<Int, String>,
|
||||||
|
extraSelectBehavior: ((Int) -> Unit)? = null
|
||||||
|
) {
|
||||||
|
val preferenceValue by PreferenceUtils.useIntPreference(preferenceKey)
|
||||||
|
|
||||||
|
var showDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
OptionsCard(
|
||||||
|
title = { OptionsTitle(title = title) },
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable(
|
||||||
|
onClick = {
|
||||||
|
showDialog = true
|
||||||
|
},
|
||||||
|
role = Role.Button
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(options[preferenceValue] ?: preferenceValue.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDialog) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
SelectDialog(
|
||||||
|
title = dialogTitle,
|
||||||
|
onDismissRequest = {
|
||||||
|
showDialog = false
|
||||||
|
},
|
||||||
|
onSelect = { selected ->
|
||||||
|
showDialog = false
|
||||||
|
PreferenceUtils.get(context)
|
||||||
|
.setInt(preferenceKey, selected)
|
||||||
|
extraSelectBehavior?.invoke(selected)
|
||||||
|
},
|
||||||
|
initialValue = preferenceValue,
|
||||||
|
) {
|
||||||
|
options.forEach { (k, v) ->
|
||||||
|
SelectOption(name = v, value = k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsStringSelectCard(
|
||||||
|
title: String,
|
||||||
|
dialogTitle: String,
|
||||||
|
preferenceKey: PreferenceUtils.Key,
|
||||||
|
options: Map<String, String>,
|
||||||
|
extraSelectBehavior: ((String?) -> Unit)? = null,
|
||||||
|
description: String? = null,
|
||||||
|
) {
|
||||||
|
val preferenceValue by PreferenceUtils.useStringPreference(preferenceKey)
|
||||||
|
|
||||||
|
var showDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
OptionsCard(
|
||||||
|
title = { OptionsTitle(title = title, description = description) },
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable(
|
||||||
|
onClick = {
|
||||||
|
showDialog = true
|
||||||
|
},
|
||||||
|
role = Role.Button
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(options[preferenceValue] ?: preferenceValue ?: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDialog) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
SelectDialog(
|
||||||
|
title = dialogTitle,
|
||||||
|
onDismissRequest = {
|
||||||
|
showDialog = false
|
||||||
|
},
|
||||||
|
onSelect = { selected ->
|
||||||
|
showDialog = false
|
||||||
|
PreferenceUtils.get(context)
|
||||||
|
.setString(preferenceKey, selected)
|
||||||
|
extraSelectBehavior?.invoke(selected)
|
||||||
|
},
|
||||||
|
initialValue = preferenceValue,
|
||||||
|
) {
|
||||||
|
options.forEach { (k, v) ->
|
||||||
|
SelectOption(name = v, value = k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsStringCard(
|
||||||
|
title: String,
|
||||||
|
dialogTitle: String,
|
||||||
|
preferenceKey: PreferenceUtils.Key,
|
||||||
|
filterInput: ((String) -> String)? = null
|
||||||
|
) {
|
||||||
|
var preferenceValue by PreferenceUtils.useStringPreference(preferenceKey)
|
||||||
|
|
||||||
|
var showDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
OptionsCard(
|
||||||
|
title = {
|
||||||
|
OptionsTitle(title = title, description = preferenceValue)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable(
|
||||||
|
onClick = {
|
||||||
|
showDialog = true
|
||||||
|
},
|
||||||
|
role = Role.Button
|
||||||
|
)
|
||||||
|
) { }
|
||||||
|
|
||||||
|
if (showDialog) {
|
||||||
|
StringDialog(
|
||||||
|
title = dialogTitle,
|
||||||
|
onDismissRequest = { showDialog = false },
|
||||||
|
onSelect = {
|
||||||
|
preferenceValue = it
|
||||||
|
showDialog = false
|
||||||
|
},
|
||||||
|
initialValue = preferenceValue ?: "",
|
||||||
|
filterInput = filterInput
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsFPSCard(
|
||||||
|
title: String,
|
||||||
|
dialogTitle: String,
|
||||||
|
preferenceKey: PreferenceUtils.Key,
|
||||||
|
maxFrameRate: Int,
|
||||||
|
description: String? = null,
|
||||||
|
) {
|
||||||
|
var preferenceValue by PreferenceUtils.useIntPreference(preferenceKey)
|
||||||
|
|
||||||
|
var showDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
OptionsCard(
|
||||||
|
title = { OptionsTitle(title = title, description = description) },
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable(
|
||||||
|
onClick = {
|
||||||
|
showDialog = true
|
||||||
|
},
|
||||||
|
role = Role.Button
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if (preferenceValue == 0) {
|
||||||
|
Text(stringResource(R.string.preference_limit_framerate_default))
|
||||||
|
} else {
|
||||||
|
Text(stringResource(R.string.preference_limit_framerate_value, preferenceValue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDialog) {
|
||||||
|
FrameRateDialog(
|
||||||
|
title = dialogTitle,
|
||||||
|
onDismissRequest = { showDialog = false },
|
||||||
|
onSelect = {
|
||||||
|
preferenceValue = it
|
||||||
|
showDialog = false
|
||||||
|
},
|
||||||
|
initialValue = preferenceValue,
|
||||||
|
maxFrameRate = maxFrameRate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsRangeCard(
|
||||||
|
title: String,
|
||||||
|
dialogTitle: String,
|
||||||
|
preferenceKey: PreferenceUtils.Key,
|
||||||
|
labelSuffix: String,
|
||||||
|
range: IntRange,
|
||||||
|
scale: Int,
|
||||||
|
step: Int,
|
||||||
|
description: String? = null,
|
||||||
|
children: @Composable () -> Unit = {}
|
||||||
|
) {
|
||||||
|
var preferenceValue by PreferenceUtils.useIntPreference(preferenceKey)
|
||||||
|
|
||||||
|
var showDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
OptionsCard(
|
||||||
|
title = {
|
||||||
|
OptionsTitle(title = title, description = description)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable(
|
||||||
|
onClick = {
|
||||||
|
showDialog = true
|
||||||
|
},
|
||||||
|
role = Role.Button
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
val precision = log10(scale.toFloat()).roundToInt()
|
||||||
|
val scaledValue = preferenceValue / scale.toFloat()
|
||||||
|
|
||||||
|
Text("%.${precision}f$labelSuffix".format(scaledValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDialog) {
|
||||||
|
RangeDialog(
|
||||||
|
title = dialogTitle,
|
||||||
|
onDismissRequest = { showDialog = false },
|
||||||
|
onSelect = {
|
||||||
|
preferenceValue = it
|
||||||
|
showDialog = false
|
||||||
|
},
|
||||||
|
initialValue = preferenceValue,
|
||||||
|
range = range,
|
||||||
|
scale = scale,
|
||||||
|
labelSuffix = labelSuffix,
|
||||||
|
step = step,
|
||||||
|
children = children,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ProfileCreateCard() {
|
||||||
|
var showDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
OptionsButton(
|
||||||
|
title = stringResource(R.string.preference_profiles_create),
|
||||||
|
icon = {
|
||||||
|
Icon(painterResource(R.drawable.icon_person_add), contentDescription = null)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
showDialog = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDialog) {
|
||||||
|
ProfileCreateDialog(onDismissRequest = {
|
||||||
|
showDialog = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ProfileSelectCard() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val profileManager = ProfileManager.get(context)
|
||||||
|
|
||||||
|
val currentProfileId = remember {
|
||||||
|
profileManager.getCurrentProfile()
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentProfileName = remember {
|
||||||
|
val savedProfile = currentProfileId
|
||||||
|
if (savedProfile == null) {
|
||||||
|
"Default"
|
||||||
|
} else {
|
||||||
|
profileManager.getProfile(savedProfile)
|
||||||
|
?.name ?: savedProfile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val clearedProfiles = remember { mutableStateListOf<String>() }
|
||||||
|
|
||||||
|
val profileList by profileManager.storedProfiles.collectAsState()
|
||||||
|
|
||||||
|
var showDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
OptionsButton(
|
||||||
|
title = stringResource(R.string.preference_profiles_select),
|
||||||
|
description = stringResource(R.string.preference_profiles_current, currentProfileName)
|
||||||
|
) {
|
||||||
|
showDialog = true
|
||||||
|
}
|
||||||
|
|
||||||
|
val activity = LocalActivity.current
|
||||||
|
if (showDialog) {
|
||||||
|
SelectDialog(
|
||||||
|
title = stringResource(R.string.preference_profiles_select),
|
||||||
|
onDismissRequest = {
|
||||||
|
showDialog = false
|
||||||
|
},
|
||||||
|
onSelect = {
|
||||||
|
showDialog = false
|
||||||
|
|
||||||
|
val selectedProfileId = it.takeIf { it.isNotEmpty() }
|
||||||
|
|
||||||
|
if (clearedProfiles.isNotEmpty()) {
|
||||||
|
profileManager.deleteProfiles(clearedProfiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentProfileId != selectedProfileId) {
|
||||||
|
profileManager
|
||||||
|
.setCurrentProfile(selectedProfileId)
|
||||||
|
|
||||||
|
activity?.run {
|
||||||
|
packageManager.getLaunchIntentForPackage(packageName)?.also { intent ->
|
||||||
|
val mainIntent = Intent.makeRestartActivityTask(intent.component)
|
||||||
|
startActivity(mainIntent)
|
||||||
|
exitProcess(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
initialValue = currentProfileId ?: "",
|
||||||
|
) {
|
||||||
|
SelectOption("Default", "")
|
||||||
|
|
||||||
|
profileList.forEach { profile ->
|
||||||
|
val path = profile.path
|
||||||
|
|
||||||
|
SelectOption(
|
||||||
|
stringResource(R.string.preference_profiles_select_value, profile.name, path),
|
||||||
|
path,
|
||||||
|
enabled = !clearedProfiles.contains(path),
|
||||||
|
leadingContent = { selected ->
|
||||||
|
IconButton(onClick = {
|
||||||
|
if (clearedProfiles.contains(path)) {
|
||||||
|
clearedProfiles.remove(path)
|
||||||
|
} else {
|
||||||
|
clearedProfiles.add(path)
|
||||||
|
}
|
||||||
|
}, enabled = !selected) {
|
||||||
|
if (clearedProfiles.contains(path)) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_undo),
|
||||||
|
contentDescription = stringResource(R.string.preference_profiles_delete_undo_alt)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_delete),
|
||||||
|
contentDescription = stringResource(R.string.preference_profiles_delete_alt)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun OptionsButton(title: String, description: String? = null, icon: (@Composable () -> Unit)? = null, displayInline: Boolean = false, onClick: () -> Unit) {
|
||||||
|
OptionsCard(
|
||||||
|
title = {
|
||||||
|
OptionsTitle(
|
||||||
|
title = title,
|
||||||
|
description = description.takeIf { !displayInline },
|
||||||
|
icon = icon
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable(onClick = onClick, role = Role.Button),
|
||||||
|
wrapContent = true
|
||||||
|
) {
|
||||||
|
if (displayInline && description != null) {
|
||||||
|
Text(description, textAlign = TextAlign.End)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun OptionsLabel(title: String, description: String? = null, icon: (@Composable () -> Unit)? = null, displayInline: Boolean = false) {
|
||||||
|
OptionsCard(
|
||||||
|
title = {
|
||||||
|
OptionsTitle(
|
||||||
|
title = title,
|
||||||
|
description = description.takeIf { !displayInline },
|
||||||
|
icon = icon
|
||||||
|
)
|
||||||
|
},
|
||||||
|
wrapContent = true
|
||||||
|
) {
|
||||||
|
if (displayInline && description != null) {
|
||||||
|
Text(description, textAlign = TextAlign.End)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsCard(title: String, description: String? = null, icon: (@Composable () -> Unit)? = null, asCard: Boolean = true, preferenceKey: PreferenceUtils.Key) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val settingEnabled = remember {
|
||||||
|
mutableStateOf(getSetting(context, preferenceKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
OptionsCard(
|
||||||
|
title = {
|
||||||
|
OptionsTitle(
|
||||||
|
title = title,
|
||||||
|
description = description,
|
||||||
|
icon = icon
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.toggleable(
|
||||||
|
value = settingEnabled.value,
|
||||||
|
onValueChange = { settingEnabled.value = toggleSetting(context, preferenceKey) },
|
||||||
|
role = Role.Switch,
|
||||||
|
),
|
||||||
|
backgroundColor = if (asCard)
|
||||||
|
MaterialTheme.colorScheme.surfaceContainer
|
||||||
|
else MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
|
) {
|
||||||
|
Switch(
|
||||||
|
checked = settingEnabled.value,
|
||||||
|
onCheckedChange = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun OptionsTitle(modifier: Modifier = Modifier, title: String, description: String? = null, icon: (@Composable () -> Unit)? = null) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (icon != null) {
|
||||||
|
icon()
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Text(title)
|
||||||
|
if (!description.isNullOrEmpty()) {
|
||||||
|
Text(
|
||||||
|
description,
|
||||||
|
style = Typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun OptionsCard(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
wrapContent: Boolean = false,
|
||||||
|
backgroundColor: Color = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
title: @Composable () -> Unit,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 1.dp)
|
||||||
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.then(modifier)
|
||||||
|
.background(backgroundColor)
|
||||||
|
.defaultMinSize(minHeight = 64.dp)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
Arrangement.SpaceBetween,
|
||||||
|
Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
if (!wrapContent) {
|
||||||
|
Box(modifier = Modifier.weight(1.0f)) {
|
||||||
|
title()
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.size(12.dp))
|
||||||
|
} else {
|
||||||
|
title()
|
||||||
|
}
|
||||||
|
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SafeModeDialog(onDismiss: () -> Unit, onLaunch: () -> Unit) {
|
||||||
|
AlertDialog(
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_shield_alert),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
title = { Text(stringResource(R.string.safe_mode_enable_title)) },
|
||||||
|
text = { Text(stringResource(R.string.safe_mode_enable_description)) },
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.message_box_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onLaunch) {
|
||||||
|
Text(stringResource(R.string.message_box_continue))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismissRequest = onDismiss
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun InlineText(label: String, icon: @Composable (() -> Unit)? = null, modifier: Modifier = Modifier) {
|
||||||
|
LabelledText(label = label, icon = icon, modifier = modifier.padding(horizontal = 16.dp, vertical = 4.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun OptionsCardPreview() {
|
||||||
|
GeodeLauncherTheme {
|
||||||
|
OptionsGroup(title = "Preview Group") {
|
||||||
|
SettingsCard(
|
||||||
|
title = "Load files from /test",
|
||||||
|
description = "Very long testing description goes here. It is incredibly long, it should wrap onto a new line.",
|
||||||
|
preferenceKey = PreferenceUtils.Key.BLACK_BACKGROUND
|
||||||
|
)
|
||||||
|
SettingsCard(
|
||||||
|
title = "Testing option 2",
|
||||||
|
preferenceKey = PreferenceUtils.Key.LOAD_AUTOMATICALLY
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,490 @@
|
|||||||
|
package com.geode.launcher.preferences.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import com.geode.launcher.R
|
||||||
|
import com.geode.launcher.ui.theme.Typography
|
||||||
|
import com.geode.launcher.utils.Profile
|
||||||
|
import com.geode.launcher.utils.ProfileManager
|
||||||
|
import kotlin.math.log10
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun StringDialog(
|
||||||
|
title: String,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onSelect: (String) -> Unit,
|
||||||
|
initialValue: String,
|
||||||
|
filterInput: ((String) -> String)? = null
|
||||||
|
) {
|
||||||
|
var enteredValue by remember { mutableStateOf(initialValue) }
|
||||||
|
|
||||||
|
BaseDialog(
|
||||||
|
onDismissRequest = { onDismissRequest() },
|
||||||
|
title = title,
|
||||||
|
content = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = enteredValue,
|
||||||
|
onValueChange = {
|
||||||
|
enteredValue = filterInput?.invoke(it) ?: it
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Ascii,
|
||||||
|
autoCorrectEnabled = false
|
||||||
|
),
|
||||||
|
trailingIcon = {
|
||||||
|
if (enteredValue.isNotEmpty()) {
|
||||||
|
IconButton(onClick = { enteredValue = "" }) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_close_small),
|
||||||
|
contentDescription = stringResource(R.string.preference_text_clear)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { onSelect(enteredValue) }) {
|
||||||
|
Text(stringResource(R.string.message_box_accept))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismissRequest) {
|
||||||
|
Text(stringResource(R.string.message_box_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RangeDialog(
|
||||||
|
title: String,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onSelect: (Int) -> Unit,
|
||||||
|
labelSuffix: String,
|
||||||
|
initialValue: Int,
|
||||||
|
range: IntRange,
|
||||||
|
scale: Int,
|
||||||
|
step: Int,
|
||||||
|
children: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
var enteredValue by remember {
|
||||||
|
mutableFloatStateOf(initialValue / scale.toFloat())
|
||||||
|
}
|
||||||
|
|
||||||
|
BaseDialog(
|
||||||
|
onDismissRequest = { onDismissRequest() },
|
||||||
|
title = title,
|
||||||
|
content = {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
val precision = log10(scale.toFloat()).roundToInt()
|
||||||
|
|
||||||
|
Slider(
|
||||||
|
value = enteredValue,
|
||||||
|
onValueChange = { enteredValue = it },
|
||||||
|
valueRange = (range.first.toFloat()/scale)..(range.last.toFloat()/scale),
|
||||||
|
steps = ((range.last - range.first) - 1) / step,
|
||||||
|
modifier = Modifier.weight(1.0f)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text("%.${precision}f$labelSuffix".format(enteredValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
children()
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { onSelect((enteredValue * scale).roundToInt()) }) {
|
||||||
|
Text(stringResource(R.string.message_box_accept))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismissRequest) {
|
||||||
|
Text(stringResource(R.string.message_box_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FrameRateDialog(
|
||||||
|
title: String,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onSelect: (Int) -> Unit,
|
||||||
|
initialValue: Int,
|
||||||
|
maxFrameRate: Int,
|
||||||
|
) {
|
||||||
|
var enteredValue by remember {
|
||||||
|
mutableStateOf(
|
||||||
|
if (initialValue == 0) maxFrameRate.toString()
|
||||||
|
else initialValue.toString()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val minFrameRate = 5
|
||||||
|
val currentValue = enteredValue.toIntOrNull()
|
||||||
|
val maximumReached = currentValue != null && currentValue > maxFrameRate
|
||||||
|
val minimumReached = currentValue != null && currentValue < minFrameRate
|
||||||
|
|
||||||
|
BaseDialog(
|
||||||
|
onDismissRequest = { onDismissRequest() },
|
||||||
|
title = title,
|
||||||
|
content = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
IconButton(onClick = {
|
||||||
|
val prevValue = max((currentValue ?: 0) - 5, minFrameRate)
|
||||||
|
enteredValue = prevValue.toString()
|
||||||
|
}, enabled = currentValue == null || currentValue > minFrameRate) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_remove),
|
||||||
|
contentDescription = stringResource(R.string.preference_limit_framerate_subtract)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = enteredValue,
|
||||||
|
onValueChange = {
|
||||||
|
enteredValue = it
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Number,
|
||||||
|
),
|
||||||
|
suffix = {
|
||||||
|
Text(stringResource(R.string.preference_limit_framerate_suffix))
|
||||||
|
},
|
||||||
|
label = {
|
||||||
|
Text(stringResource(R.string.preference_limit_framerate_label))
|
||||||
|
},
|
||||||
|
isError = minimumReached || maximumReached,
|
||||||
|
modifier = Modifier.weight(1.0f),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
|
||||||
|
IconButton(onClick = {
|
||||||
|
val nextValue = min((currentValue ?: 0) + 5, maxFrameRate)
|
||||||
|
enteredValue = nextValue.toString()
|
||||||
|
}, enabled = currentValue == null || currentValue < maxFrameRate) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_add),
|
||||||
|
contentDescription = stringResource(R.string.preference_limit_framerate_add)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(onClick = { onSelect(0) }) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_delete),
|
||||||
|
contentDescription = stringResource(R.string.preference_limit_framerate_reset)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (minimumReached) {
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.preference_limit_framerate_error_min, minFrameRate),
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
)
|
||||||
|
} else if (maximumReached) {
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
R.string.preference_limit_framerate_error_max,
|
||||||
|
maxFrameRate
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
// automatically disable if already max
|
||||||
|
if (currentValue == maxFrameRate) {
|
||||||
|
onSelect(0)
|
||||||
|
} else {
|
||||||
|
onSelect(currentValue ?: 0)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = !minimumReached && !maximumReached && currentValue != null
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.message_box_accept))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismissRequest) {
|
||||||
|
Text(stringResource(R.string.message_box_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ProfileCreateDialog(onDismissRequest: () -> Unit) {
|
||||||
|
var enteredValue by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
val filename = enteredValue.take(16)
|
||||||
|
.lowercase()
|
||||||
|
.map {
|
||||||
|
if ("qwertyuiopasdfghjklzxcvbnm1234567890-_.".contains(it))
|
||||||
|
it else '_'
|
||||||
|
}
|
||||||
|
.joinToString("")
|
||||||
|
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
val minimumReached = enteredValue.isEmpty() || filename.isEmpty()
|
||||||
|
|
||||||
|
val profileManager = ProfileManager.get(context)
|
||||||
|
val currentProfiles = remember {
|
||||||
|
profileManager.getProfiles().map { it.path }.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
val isDuplicate = currentProfiles.contains(filename)
|
||||||
|
|
||||||
|
BaseDialog(
|
||||||
|
onDismissRequest = { onDismissRequest() },
|
||||||
|
title = stringResource(R.string.preference_profiles_create),
|
||||||
|
content = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = enteredValue,
|
||||||
|
onValueChange = {
|
||||||
|
enteredValue = it
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Text,
|
||||||
|
),
|
||||||
|
label = {
|
||||||
|
Text(stringResource(R.string.preference_profiles_create_name))
|
||||||
|
},
|
||||||
|
isError = isDuplicate,
|
||||||
|
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isDuplicate) {
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
|
||||||
|
Text(stringResource(R.string.preference_profiles_create_duplicate))
|
||||||
|
} else if (!minimumReached) {
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
|
||||||
|
Text(stringResource(R.string.preference_profiles_create_info, filename))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
ProfileManager.get(context).storeProfile(Profile(filename, enteredValue))
|
||||||
|
onDismissRequest()
|
||||||
|
},
|
||||||
|
enabled = !minimumReached && !isDuplicate
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.preference_profiles_create_action))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismissRequest) {
|
||||||
|
Text(stringResource(R.string.message_box_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BaseDialog(
|
||||||
|
title: String,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
dismissButton: @Composable () -> Unit,
|
||||||
|
confirmButton: @Composable () -> Unit,
|
||||||
|
content: @Composable (ColumnScope.() -> Unit),
|
||||||
|
) {
|
||||||
|
Dialog(onDismissRequest = onDismissRequest) {
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
// styling a dialog is actually a little hard if you're doing what i'm doing
|
||||||
|
// maybe there's a better way to make these padding values...
|
||||||
|
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style = Typography.titleLarge,
|
||||||
|
modifier = Modifier.padding(
|
||||||
|
start = 28.dp,
|
||||||
|
top = 24.dp,
|
||||||
|
bottom = 12.dp
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
content()
|
||||||
|
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(
|
||||||
|
bottom = 16.dp,
|
||||||
|
end = 16.dp,
|
||||||
|
top = 4.dp
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
dismissButton()
|
||||||
|
confirmButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal val LocalSelectValue = compositionLocalOf<Any> { 0 }
|
||||||
|
internal val LocalSelectSetValue = staticCompositionLocalOf<(Any) -> Unit> { {} }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun <T> SelectOption(name: String, value: T, enabled: Boolean = true, leadingContent: @Composable (Boolean) -> Unit = {}) {
|
||||||
|
val currentValue = LocalSelectValue.current
|
||||||
|
val setValue = LocalSelectSetValue.current
|
||||||
|
|
||||||
|
val isSelected = currentValue.equals(value)
|
||||||
|
|
||||||
|
// do not give the row or column padding!! it messes up the selection effect
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(
|
||||||
|
onClick = { setValue(value as Any) },
|
||||||
|
role = Role.RadioButton,
|
||||||
|
enabled = enabled
|
||||||
|
)
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
) {
|
||||||
|
Row(modifier = Modifier.weight(1.0f, true), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
RadioButton(
|
||||||
|
selected = isSelected,
|
||||||
|
onClick = { setValue(value as Any) },
|
||||||
|
enabled = enabled
|
||||||
|
)
|
||||||
|
Text(name, style = Typography.bodyMedium)
|
||||||
|
}
|
||||||
|
|
||||||
|
leadingContent(isSelected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun <T> SelectDialog(
|
||||||
|
title: String,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onSelect: (T) -> Unit,
|
||||||
|
initialValue: T,
|
||||||
|
options: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val (selectedValue, setSelectedValue) = remember { mutableStateOf(initialValue) }
|
||||||
|
|
||||||
|
BaseDialog(
|
||||||
|
title = title,
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismissRequest) {
|
||||||
|
Text(stringResource(R.string.message_box_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { onSelect(selectedValue) }) {
|
||||||
|
Text(stringResource(R.string.message_box_accept))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalSelectValue provides (selectedValue as Any),
|
||||||
|
LocalSelectSetValue provides ({
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
setSelectedValue(it as T)
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
options()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.geode.launcher.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.geode.launcher.R
|
||||||
|
|
||||||
|
val robotoMonoFamily = FontFamily(
|
||||||
|
Font(R.font.robotomono_medium, FontWeight.Medium),
|
||||||
|
Font(R.font.robotomono_regular, FontWeight.Normal)
|
||||||
|
)
|
||||||
|
|
||||||
|
val quicksandFamily = FontFamily(
|
||||||
|
Font(R.font.quicksand_semibold, FontWeight.SemiBold)
|
||||||
|
)
|
||||||
|
|
||||||
|
val googleSansFamily = FontFamily(
|
||||||
|
Font(R.font.googlesans_medium, FontWeight.Medium)
|
||||||
|
)
|
||||||
|
|
||||||
|
val launcherTitleStyle = TextStyle(
|
||||||
|
fontFamily = quicksandFamily,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 32.sp,
|
||||||
|
letterSpacing = 0.sp
|
||||||
|
)
|
||||||
|
|
||||||
|
val sapphireTitleStyle = TextStyle(
|
||||||
|
fontFamily = googleSansFamily,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 32.sp,
|
||||||
|
letterSpacing = 0.sp
|
||||||
|
)
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.geode.launcher.ui.theme
|
package com.geode.launcher.ui.theme
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -9,14 +8,9 @@ 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,
|
||||||
@@ -28,16 +22,6 @@ 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 {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.geode.launcher.updater
|
package com.geode.launcher.updater
|
||||||
|
|
||||||
import com.geode.launcher.utils.LaunchUtils
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
import kotlinx.datetime.Instant
|
import kotlin.time.Instant
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -13,10 +13,11 @@ data class Asset(
|
|||||||
val createdAt: Instant,
|
val createdAt: Instant,
|
||||||
val updatedAt: Instant,
|
val updatedAt: Instant,
|
||||||
val browserDownloadUrl: String,
|
val browserDownloadUrl: String,
|
||||||
|
val digest: String?,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
class Release(
|
data class Release(
|
||||||
val url: String,
|
val url: String,
|
||||||
val id: Int,
|
val id: Int,
|
||||||
val targetCommitish: String,
|
val targetCommitish: String,
|
||||||
@@ -24,40 +25,141 @@ class Release(
|
|||||||
val createdAt: Instant,
|
val createdAt: Instant,
|
||||||
val publishedAt: Instant,
|
val publishedAt: Instant,
|
||||||
val assets: List<Asset>,
|
val assets: List<Asset>,
|
||||||
val htmlUrl: String
|
val htmlUrl: String,
|
||||||
) {
|
val body: String?,
|
||||||
fun getDescription(): String {
|
val name: String?,
|
||||||
if (tagName == "nightly") {
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LoaderPlatformDownload(
|
||||||
|
val url: String,
|
||||||
|
val hash: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LoaderDownload(
|
||||||
|
val win: LoaderPlatformDownload,
|
||||||
|
val mac: LoaderPlatformDownload,
|
||||||
|
val android32: LoaderPlatformDownload,
|
||||||
|
val android64: LoaderPlatformDownload,
|
||||||
|
val ios: LoaderPlatformDownload,
|
||||||
|
val resources: LoaderPlatformDownload,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LoaderVersion(
|
||||||
|
val tag: String,
|
||||||
|
val version: String,
|
||||||
|
val createdAt: Instant,
|
||||||
|
val commitHash: String,
|
||||||
|
val prerelease: Boolean,
|
||||||
|
val downloads: LoaderDownload,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LoaderPayload<T>(
|
||||||
|
val payload: T?,
|
||||||
|
val error: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DownloadableAsset(
|
||||||
|
val url: String,
|
||||||
|
val filename: String,
|
||||||
|
val size: Long? = null,
|
||||||
|
val hash: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun mapDownload(download: Asset): DownloadableAsset {
|
||||||
|
val hash = if (download.digest?.startsWith("sha256:") == true)
|
||||||
|
download.digest.removePrefix("sha256:")
|
||||||
|
else null
|
||||||
|
|
||||||
|
return DownloadableAsset(
|
||||||
|
url = download.browserDownloadUrl,
|
||||||
|
filename = download.name,
|
||||||
|
size = download.size.toLong(),
|
||||||
|
hash = hash,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class Downloadable {
|
||||||
|
/**
|
||||||
|
* Human-readable representation of the current version.
|
||||||
|
*/
|
||||||
|
abstract fun getDescription(): String
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unique value that refers to the current version, used for comparison.
|
||||||
|
*/
|
||||||
|
abstract fun getDescriptor(): Long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release download link for the current platform
|
||||||
|
*/
|
||||||
|
abstract fun getDownload(): DownloadableAsset?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resources download link for current platform.
|
||||||
|
*/
|
||||||
|
abstract fun getResourcesDownload(): DownloadableAsset?
|
||||||
|
}
|
||||||
|
|
||||||
|
class DownloadableGitHubLoaderRelease(private val release: Release) : Downloadable() {
|
||||||
|
override fun getDescription(): String {
|
||||||
|
if (release.tagName == "nightly") {
|
||||||
// get the commit from the assets
|
// get the commit from the assets
|
||||||
// otherwise, a separate request is needed to get the hash (ew)
|
// otherwise, a separate request is needed to get the hash (ew)
|
||||||
val asset = assets.first()
|
val asset = release.assets.first()
|
||||||
val commit = asset.name.substring(6..12)
|
val commit = asset.name.substring(6..12)
|
||||||
|
|
||||||
return "nightly-$commit"
|
return "nightly-$commit"
|
||||||
}
|
}
|
||||||
|
|
||||||
return tagName
|
return release.tagName
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getDescriptor(): Long {
|
override fun getDescriptor(): Long {
|
||||||
return createdAt.epochSeconds
|
return release.createdAt.epochSeconds
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getGeodeDownload(): Asset? {
|
private fun getGitHubDownload(): Asset? {
|
||||||
// try to find an asset that matches the architecture first
|
// try to find an asset that matches the architecture first
|
||||||
val platform = LaunchUtils.platformName
|
val platform = LaunchUtils.platformName
|
||||||
|
|
||||||
val releaseSuffix = "$platform.zip"
|
val releaseSuffix = "$platform.zip"
|
||||||
return assets.find {
|
return release.assets.find {
|
||||||
it.name.endsWith(releaseSuffix)
|
it.name.endsWith(releaseSuffix)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getLauncherDownload(): Asset? {
|
override fun getDownload(): DownloadableAsset? {
|
||||||
|
val download = getGitHubDownload() ?: return null
|
||||||
|
return mapDownload(download)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getResourcesDownload(): DownloadableAsset? {
|
||||||
|
val download = release.assets.find {
|
||||||
|
it.name == "resources.zip"
|
||||||
|
} ?: return null
|
||||||
|
|
||||||
|
return mapDownload(download)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DownloadableLauncherRelease(val release: Release) : Downloadable() {
|
||||||
|
override fun getDescription(): String {
|
||||||
|
return release.tagName
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getDescriptor(): Long {
|
||||||
|
return release.createdAt.epochSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getGitHubDownload(): Asset? {
|
||||||
val platform = LaunchUtils.platformName
|
val platform = LaunchUtils.platformName
|
||||||
val use32BitPlatform = platform == "android32"
|
val use32BitPlatform = platform == "android32"
|
||||||
|
|
||||||
return assets.find { asset ->
|
return release.assets.find { asset ->
|
||||||
/* you know it's good when you pull out the truth table
|
/* you know it's good when you pull out the truth table
|
||||||
* u32bp | contains | found
|
* u32bp | contains | found
|
||||||
* 1 | 1 | 1
|
* 1 | 1 | 1
|
||||||
@@ -66,8 +168,46 @@ class Release(
|
|||||||
* 0 | 0 | 1
|
* 0 | 0 | 1
|
||||||
* surprise! it's an xnor
|
* surprise! it's an xnor
|
||||||
*/
|
*/
|
||||||
|
asset.name.endsWith("apk") && asset.name.contains("android32") == use32BitPlatform
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
(asset.name.contains("android32")) == use32BitPlatform
|
override fun getDownload(): DownloadableAsset? {
|
||||||
|
val download = getGitHubDownload() ?: return null
|
||||||
|
return mapDownload(download)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getResourcesDownload(): DownloadableAsset? {
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class DownloadableLoaderRelease(private val version: LoaderVersion) : Downloadable() {
|
||||||
|
override fun getDescription(): String {
|
||||||
|
return version.tag
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getDescriptor(): Long {
|
||||||
|
return version.createdAt.epochSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getDownload(): DownloadableAsset {
|
||||||
|
val data = if (LaunchUtils.is64bit) version.downloads.android64 else version.downloads.android32
|
||||||
|
val filename = "geode-${version.tag}-${LaunchUtils.platformName}.zip"
|
||||||
|
return DownloadableAsset(
|
||||||
|
url = data.url,
|
||||||
|
filename = filename,
|
||||||
|
hash = data.hash,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getResourcesDownload(): DownloadableAsset {
|
||||||
|
val data = version.downloads.resources
|
||||||
|
val filename = "resources.zip"
|
||||||
|
return DownloadableAsset(
|
||||||
|
url = data.url,
|
||||||
|
filename = filename,
|
||||||
|
hash = data.hash,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -18,10 +18,18 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import okhttp3.Cache
|
import okhttp3.Cache
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
import okio.HashingSink
|
||||||
|
import okio.blackholeSink
|
||||||
import okio.buffer
|
import okio.buffer
|
||||||
|
import okio.sink
|
||||||
import okio.source
|
import okio.source
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.io.FileNotFoundException
|
||||||
import java.io.InterruptedIOException
|
import java.io.InterruptedIOException
|
||||||
|
import java.io.IOException
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
private const val TAG_LATEST = "latest"
|
private const val TAG_LATEST = "latest"
|
||||||
private const val TAG_BETA = "prerelease"
|
private const val TAG_BETA = "prerelease"
|
||||||
@@ -61,13 +69,14 @@ class ReleaseManager private constructor(
|
|||||||
sealed class ReleaseManagerState {
|
sealed class ReleaseManagerState {
|
||||||
data object InUpdateCheck : ReleaseManagerState()
|
data object InUpdateCheck : ReleaseManagerState()
|
||||||
data class Failure(val exception: Exception) : ReleaseManagerState()
|
data class Failure(val exception: Exception) : ReleaseManagerState()
|
||||||
data class InDownload(val downloaded: Long, val outOf: Long) : ReleaseManagerState()
|
data class InDownload(val downloaded: Long, val outOf: Long?) : ReleaseManagerState()
|
||||||
data class Finished(val hasUpdated: Boolean = false) : ReleaseManagerState()
|
data class Finished(val hasUpdated: Boolean = false) : ReleaseManagerState()
|
||||||
}
|
}
|
||||||
|
|
||||||
class UpdateException(reason: Reason? = null, cause: Throwable? = null) : Exception(reason?.name, cause) {
|
class UpdateException(reason: Reason? = null, cause: Throwable? = null) : Exception(reason?.name, cause) {
|
||||||
enum class Reason {
|
enum class Reason {
|
||||||
EXTERNAL_FILE_IN_USE
|
EXTERNAL_FILE_IN_USE,
|
||||||
|
HASH_VALIDATION_FAILED
|
||||||
}
|
}
|
||||||
|
|
||||||
var reason: Reason? = reason
|
var reason: Reason? = reason
|
||||||
@@ -82,10 +91,18 @@ class ReleaseManager private constructor(
|
|||||||
val isInUpdate: Boolean
|
val isInUpdate: Boolean
|
||||||
get() = _uiState.value !is ReleaseManagerState.Failure && _uiState.value !is ReleaseManagerState.Finished
|
get() = _uiState.value !is ReleaseManagerState.Failure && _uiState.value !is ReleaseManagerState.Finished
|
||||||
|
|
||||||
private val _availableLauncherUpdate = MutableStateFlow<Release?>(null)
|
private val _availableLauncherUpdateTag = MutableStateFlow<String?>(null)
|
||||||
val availableLauncherUpdate = _availableLauncherUpdate.asStateFlow()
|
val availableLauncherUpdateTag = _availableLauncherUpdateTag.asStateFlow()
|
||||||
|
|
||||||
|
private val _availableLauncherUpdateDetails = MutableStateFlow<DownloadableLauncherRelease?>(null)
|
||||||
|
val availableLauncherUpdateDetails = _availableLauncherUpdateDetails.asStateFlow()
|
||||||
|
|
||||||
|
var dismissedLauncherUpdate = false
|
||||||
|
|
||||||
|
var skipStateUpdate = false
|
||||||
|
|
||||||
private fun sendError(e: Exception) {
|
private fun sendError(e: Exception) {
|
||||||
|
skipStateUpdate = true
|
||||||
_uiState.value = ReleaseManagerState.Failure(e)
|
_uiState.value = ReleaseManagerState.Failure(e)
|
||||||
|
|
||||||
// ignore cancellation, it's good actually
|
// ignore cancellation, it's good actually
|
||||||
@@ -106,61 +123,122 @@ class ReleaseManager private constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getBestReleaseForGameVersion(): String? {
|
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)) {
|
if (!GamePackageUtils.isGameInstalled(applicationContext.packageManager)) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
val gameVersion = GamePackageUtils.getGameVersionCode(applicationContext.packageManager)
|
val gameVersion = GamePackageUtils.getGameVersionCode(applicationContext.packageManager)
|
||||||
|
val preferences = PreferenceUtils.get(applicationContext)
|
||||||
|
|
||||||
return when {
|
val targetTag = preferences.getInt(PreferenceUtils.Key.RELEASE_CHANNEL_TAG)
|
||||||
gameVersion >= 39L -> mapSelectedReleaseToTag()
|
|
||||||
gameVersion == 38L -> "v2.0.0-beta.27"
|
|
||||||
gameVersion == 37L -> "v2.0.0-beta.5"
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getLatestRelease(): Release? {
|
|
||||||
val targetTag = getBestReleaseForGameVersion() ?: return null
|
|
||||||
|
|
||||||
return when (targetTag) {
|
return when (targetTag) {
|
||||||
TAG_LATEST -> releaseRepository.getLatestGeodeRelease()
|
2 -> releaseRepository.getReleaseByTag("nightly")
|
||||||
TAG_BETA -> releaseRepository.getLatestGeodePreRelease()
|
1 -> releaseRepository.getLatestIndexRelease(gameVersion, true)
|
||||||
else -> releaseRepository.getReleaseByTag(targetTag)
|
else -> releaseRepository.getLatestIndexRelease(gameVersion, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun downloadLauncherUpdate(release: Release) {
|
private suspend fun downloadLauncherUpdate(release: Downloadable): Result<File> {
|
||||||
val download = release.getLauncherDownload() ?: return
|
val download = release.getDownload()
|
||||||
|
?: return Result.failure(FileNotFoundException())
|
||||||
|
|
||||||
val outputDirectory = LaunchUtils.getBaseDirectory(applicationContext)
|
val outputDirectory = applicationContext.filesDir
|
||||||
val outputFile = File(outputDirectory, download.name)
|
val outputFile = File(outputDirectory, download.filename)
|
||||||
|
|
||||||
if (outputFile.exists()) {
|
|
||||||
// only download the apk once
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_uiState.value = ReleaseManagerState.InDownload(0, download.size.toLong())
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val fileStream = DownloadUtils.downloadStream(
|
val hash = DownloadUtils.downloadFile(
|
||||||
httpClient,
|
httpClient,
|
||||||
download.browserDownloadUrl
|
download.url,
|
||||||
) { progress, outOf ->
|
outputFile
|
||||||
_uiState.value = ReleaseManagerState.InDownload(progress, outOf)
|
)
|
||||||
|
|
||||||
|
if (download.hash != null && download.hash != hash) {
|
||||||
|
Log.w("ReleaseManager", "downloadLauncherUpdate failed: found $hash but expected ${download.hash}")
|
||||||
|
outputFile.delete()
|
||||||
|
throw UpdateException(UpdateException.Reason.HASH_VALIDATION_FAILED)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return Result.failure(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
fileStream.copyTo(outputFile.outputStream())
|
return Result.success(outputFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun performResourceDownload(resourceAsset: DownloadableAsset, initialSize: Long): Boolean {
|
||||||
|
val guessSize = if (resourceAsset.size == null) null else initialSize + resourceAsset.size
|
||||||
|
val guessInitial = if (guessSize == null) 0 else initialSize
|
||||||
|
_uiState.value = ReleaseManagerState.InDownload(guessInitial, guessSize)
|
||||||
|
|
||||||
|
val outputDir = getTempResourcesDirectory()
|
||||||
|
val outputPath = outputDir.path
|
||||||
|
|
||||||
|
val finalDir = LaunchUtils.getGeodeResourcesDirectory(applicationContext)
|
||||||
|
|
||||||
|
val downloadFile = getTempFile("resources.zip")
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!outputDir.exists()) {
|
||||||
|
outputDir.mkdirs()
|
||||||
|
}
|
||||||
|
|
||||||
|
val hash = DownloadUtils.downloadFile(
|
||||||
|
httpClient,
|
||||||
|
resourceAsset.url,
|
||||||
|
downloadFile,
|
||||||
|
onProgress = { progress, outOf ->
|
||||||
|
if (!skipStateUpdate)
|
||||||
|
_uiState.value = ReleaseManagerState.InDownload(initialSize + progress, outOf + initialSize)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (resourceAsset.hash != null && resourceAsset.hash != hash) {
|
||||||
|
Log.w("ReleaseManager", "performResourceDownload failed: found $hash but expected ${resourceAsset.hash}")
|
||||||
|
throw UpdateException(UpdateException.Reason.HASH_VALIDATION_FAILED)
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadFile.inputStream().use { zip ->
|
||||||
|
DownloadUtils.copyZipStreamToDirectory(
|
||||||
|
zip,
|
||||||
|
outputDir
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalDir.exists()) {
|
||||||
|
finalDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!outputDir.renameTo(finalDir)) {
|
||||||
|
println("Failed to rename temporary directory!")
|
||||||
|
DownloadUtils.copyDirectory(outputDir, finalDir)
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
sendError(e)
|
sendError(e)
|
||||||
return
|
return false
|
||||||
}
|
} finally {
|
||||||
|
val tempPathClone = File(outputPath)
|
||||||
|
try {
|
||||||
|
if (downloadFile.exists()) downloadFile.delete()
|
||||||
|
if (tempPathClone.exists()) tempPathClone.deleteRecursively()
|
||||||
|
} catch (_: IOException) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun performUpdate(release: Release) {
|
return true
|
||||||
val releaseAsset = release.getGeodeDownload()
|
}
|
||||||
|
|
||||||
|
private suspend fun performUpdate(release: Downloadable) {
|
||||||
|
skipStateUpdate = false
|
||||||
|
|
||||||
|
val releaseAsset = release.getDownload()
|
||||||
if (releaseAsset == null) {
|
if (releaseAsset == null) {
|
||||||
val noAssetException = Exception("missing Android download")
|
val noAssetException = Exception("missing Android download")
|
||||||
_uiState.value = ReleaseManagerState.Failure(noAssetException)
|
_uiState.value = ReleaseManagerState.Failure(noAssetException)
|
||||||
@@ -168,74 +246,203 @@ class ReleaseManager private constructor(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// set an initial download size
|
val resourcesAsset = release.getResourcesDownload()
|
||||||
_uiState.value = ReleaseManagerState.InDownload(0, releaseAsset.size.toLong())
|
|
||||||
|
|
||||||
try {
|
var releaseSize = releaseAsset.size ?: 0L
|
||||||
val fileStream = DownloadUtils.downloadStream(
|
val resourcesSize = resourcesAsset?.size ?: 0L
|
||||||
httpClient,
|
|
||||||
releaseAsset.browserDownloadUrl
|
// set an initial download size
|
||||||
) { progress, outOf ->
|
val initialSize = if (releaseAsset.size == null && resourcesAsset?.size == null) null else releaseSize + resourcesSize
|
||||||
_uiState.value = ReleaseManagerState.InDownload(progress, outOf)
|
_uiState.value = ReleaseManagerState.InDownload(0, initialSize)
|
||||||
}
|
|
||||||
|
val outputFile = getTempFile(LaunchUtils.geodeFilename)
|
||||||
|
// clone the file instance as renameTo may move the original file
|
||||||
|
val tempFilePath = outputFile.path
|
||||||
|
|
||||||
val geodeFile = getGeodeOutputPath()
|
val geodeFile = getGeodeOutputPath()
|
||||||
|
|
||||||
|
val downloadFile = getTempFile("geode.zip")
|
||||||
|
|
||||||
|
try {
|
||||||
|
// tempFile should be in same path as geodeFile
|
||||||
|
val geodeParent = geodeFile.parentFile
|
||||||
|
if (geodeParent != null && !geodeParent.exists()) {
|
||||||
|
geodeParent.mkdirs()
|
||||||
|
}
|
||||||
|
|
||||||
|
val hash = DownloadUtils.downloadFile(
|
||||||
|
httpClient,
|
||||||
|
releaseAsset.url,
|
||||||
|
downloadFile,
|
||||||
|
onProgress = { progress, outOf ->
|
||||||
|
if (!skipStateUpdate)
|
||||||
|
_uiState.value = ReleaseManagerState.InDownload(progress, outOf + resourcesSize)
|
||||||
|
|
||||||
|
releaseSize = max(outOf, releaseSize)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (releaseAsset.hash != null && releaseAsset.hash != hash) {
|
||||||
|
Log.w("ReleaseManager", "performUpdate failed: found $hash but expected ${releaseAsset.hash}")
|
||||||
|
throw UpdateException(UpdateException.Reason.HASH_VALIDATION_FAILED)
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadFile.inputStream().use { zip ->
|
||||||
|
DownloadUtils.extractFileFromZipStream(
|
||||||
|
zip,
|
||||||
|
outputFile,
|
||||||
|
geodeFile.name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// work around a permission issue from adb push
|
// work around a permission issue from adb push
|
||||||
if (geodeFile.exists()) {
|
if (geodeFile.exists()) {
|
||||||
geodeFile.delete()
|
geodeFile.delete()
|
||||||
}
|
}
|
||||||
|
|
||||||
geodeFile.parentFile?.mkdirs()
|
val renameSuccessful = runCatching {
|
||||||
|
outputFile.renameTo(geodeFile)
|
||||||
|
}.getOrDefault(false)
|
||||||
|
|
||||||
DownloadUtils.extractFileFromZipStream(
|
if (!renameSuccessful) {
|
||||||
fileStream,
|
// attempt a manual copy if rename fails for whatever reason
|
||||||
geodeFile.outputStream(),
|
DownloadUtils.copyFile(outputFile, geodeFile)
|
||||||
geodeFile.name
|
}
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
sendError(e)
|
sendError(e)
|
||||||
return
|
return
|
||||||
|
} finally {
|
||||||
|
val tempFileClone = File(tempFilePath)
|
||||||
|
try {
|
||||||
|
if (downloadFile.exists()) downloadFile.delete()
|
||||||
|
if (tempFileClone.exists()) tempFileClone.delete()
|
||||||
|
} catch (_: IOException) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadLauncherUpdateIfNecessary()
|
if (resourcesAsset != null) {
|
||||||
|
if (!performResourceDownload(resourcesAsset, releaseSize)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// extraction performed
|
// extraction performed
|
||||||
updatePreferences(release)
|
updatePreferences(release)
|
||||||
|
|
||||||
|
val currentTime = Clock.System.now().toEpochMilliseconds()
|
||||||
|
PreferenceUtils.get(applicationContext)
|
||||||
|
.setLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME, currentTime)
|
||||||
|
|
||||||
_uiState.value = ReleaseManagerState.Finished(true)
|
_uiState.value = ReleaseManagerState.Finished(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fileWasExternallyModified(): Boolean {
|
private fun fileWasExternallyModified(modifiedOnly: Boolean = false): Boolean {
|
||||||
|
// make sure geode is still here. just in case
|
||||||
val geodeFile = getGeodeOutputPath()
|
val geodeFile = getGeodeOutputPath()
|
||||||
if (!geodeFile.exists()) {
|
if (!geodeFile.exists()) {
|
||||||
return false
|
return !modifiedOnly
|
||||||
}
|
}
|
||||||
|
|
||||||
val sharedPreferences = PreferenceUtils.get(applicationContext)
|
val sharedPreferences = PreferenceUtils.get(applicationContext)
|
||||||
if (!sharedPreferences.getBoolean(PreferenceUtils.Key.DEVELOPER_MODE)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
val originalFileHash = sharedPreferences.getString(PreferenceUtils.Key.CURRENT_RELEASE_MODIFIED)
|
val originalFileHash = sharedPreferences.getString(PreferenceUtils.Key.CURRENT_RELEASE_MODIFIED)
|
||||||
?: return false
|
?: return true
|
||||||
|
|
||||||
val currentFileHash = computeFileHash(geodeFile)
|
val currentFileHash = computeFileHash(geodeFile)
|
||||||
|
|
||||||
return originalFileHash != currentFileHash
|
return originalFileHash != currentFileHash
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun checkLauncherUpdate(launcherUpdate: Release) {
|
private suspend fun getLatestLauncherUpdate(): DownloadableLauncherRelease? {
|
||||||
if (launcherUpdate.tagName != BuildConfig.VERSION_NAME) {
|
if (BuildConfig.GOOGLE_PLAY_BUILD) {
|
||||||
_availableLauncherUpdate.value = launcherUpdate
|
// don't check for updates on Google Play builds
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
releaseRepository.getLatestLauncherRelease()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
sendError(e)
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun downloadLauncherUpdateIfNecessary() {
|
suspend fun checkLauncherUpdate() {
|
||||||
val update = _availableLauncherUpdate.value ?: return
|
if (BuildConfig.GOOGLE_PLAY_BUILD) return
|
||||||
downloadLauncherUpdate(update)
|
|
||||||
|
val launcherRelease = getLatestLauncherUpdate() ?: return
|
||||||
|
|
||||||
|
// unconditionally track it, we're not using it for checks and it might be good to have
|
||||||
|
_availableLauncherUpdateDetails.value = launcherRelease
|
||||||
|
|
||||||
|
val launcherTag = launcherRelease.release.tagName
|
||||||
|
|
||||||
|
PreferenceUtils.get(applicationContext)
|
||||||
|
.setString(PreferenceUtils.Key.LAST_LAUNCHER_UPDATE, launcherTag)
|
||||||
|
|
||||||
|
if (launcherTag != BuildConfig.VERSION_NAME) {
|
||||||
|
_availableLauncherUpdateTag.value = launcherTag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun checkCachedLauncherUpdate() {
|
||||||
|
if (BuildConfig.GOOGLE_PLAY_BUILD) return
|
||||||
|
|
||||||
|
val lastUpdate = PreferenceUtils.get(applicationContext)
|
||||||
|
.getString(PreferenceUtils.Key.LAST_LAUNCHER_UPDATE) ?: return
|
||||||
|
|
||||||
|
if (lastUpdate != BuildConfig.VERSION_NAME) {
|
||||||
|
_availableLauncherUpdateTag.value = lastUpdate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun downloadLatestLauncherUpdate(): Result<File> {
|
||||||
|
if (BuildConfig.GOOGLE_PLAY_BUILD) {
|
||||||
|
return Result.failure(UnsupportedOperationException(
|
||||||
|
"Launcher updates are not supported on Google Play builds"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
val update = _availableLauncherUpdateDetails.value
|
||||||
|
?: getLatestLauncherUpdate()
|
||||||
|
?: return Result.failure(FileNotFoundException())
|
||||||
|
|
||||||
|
return downloadLauncherUpdate(update)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shouldUseCache(): Boolean {
|
||||||
|
if (BuildConfig.GOOGLE_PLAY_BUILD) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
val sharedPreferences = PreferenceUtils.get(applicationContext)
|
||||||
|
if (sharedPreferences.getBoolean(PreferenceUtils.Key.DISABLE_UPDATE_CACHE)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val lastCheckTime = sharedPreferences.getLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME)
|
||||||
|
|
||||||
|
// only check for updates if it's been over 15 minutes since last check
|
||||||
|
val checkMinTime = Clock.System.now().minus(15.minutes).toEpochMilliseconds()
|
||||||
|
return lastCheckTime > checkMinTime && !fileWasExternallyModified()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun afterUseCachedUpdate() {
|
||||||
|
checkCachedLauncherUpdate()
|
||||||
|
_uiState.tryEmit(ReleaseManagerState.Finished())
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun checkForNewRelease(isManual: Boolean = false) {
|
||||||
|
if (BuildConfig.GOOGLE_PLAY_BUILD) {
|
||||||
|
_uiState.value = ReleaseManagerState.Finished()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val sharedPreferences = PreferenceUtils.get(applicationContext)
|
||||||
|
|
||||||
|
if (!isManual && shouldUseCache()) {
|
||||||
|
afterUseCachedUpdate()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun checkForNewRelease(allowOverwriting: Boolean = false) {
|
|
||||||
val release = try {
|
val release = try {
|
||||||
getLatestRelease()
|
getLatestRelease()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -243,44 +450,32 @@ class ReleaseManager private constructor(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val launcherRelease = try {
|
checkLauncherUpdate()
|
||||||
releaseRepository.getLatestLauncherRelease()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
sendError(e)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (launcherRelease != null) {
|
|
||||||
checkLauncherUpdate(launcherRelease)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (release == null) {
|
if (release == null) {
|
||||||
downloadLauncherUpdateIfNecessary()
|
val currentTime = Clock.System.now().toEpochMilliseconds()
|
||||||
|
sharedPreferences.setLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME, currentTime)
|
||||||
|
|
||||||
_uiState.value = ReleaseManagerState.Finished()
|
_uiState.value = ReleaseManagerState.Finished()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val sharedPreferences = PreferenceUtils.get(applicationContext)
|
|
||||||
|
|
||||||
val currentVersion = sharedPreferences.getLong(PreferenceUtils.Key.CURRENT_VERSION_TIMESTAMP)
|
val currentVersion = sharedPreferences.getLong(PreferenceUtils.Key.CURRENT_VERSION_TIMESTAMP)
|
||||||
val latestVersion = release.getDescriptor()
|
val latestVersion = release.getDescriptor()
|
||||||
|
|
||||||
// make sure geode is still here. just in case
|
|
||||||
val geodeFile = getGeodeOutputPath()
|
|
||||||
|
|
||||||
// check if an update is needed
|
// check if an update is needed
|
||||||
if (latestVersion == currentVersion && geodeFile.exists()) {
|
if (latestVersion == currentVersion && !fileWasExternallyModified()) {
|
||||||
downloadLauncherUpdateIfNecessary()
|
val currentTime = Clock.System.now().toEpochMilliseconds()
|
||||||
|
sharedPreferences.setLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME, currentTime)
|
||||||
|
|
||||||
_uiState.value = ReleaseManagerState.Finished()
|
_uiState.value = ReleaseManagerState.Finished()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if the file was externally modified
|
val allowOverwriting = !sharedPreferences.getBoolean(PreferenceUtils.Key.DEVELOPER_MODE) || isManual
|
||||||
if (!allowOverwriting && fileWasExternallyModified()) {
|
|
||||||
downloadLauncherUpdateIfNecessary()
|
|
||||||
|
|
||||||
|
// check if the file was externally modified
|
||||||
|
if (!allowOverwriting && fileWasExternallyModified(true)) {
|
||||||
sendError(UpdateException(UpdateException.Reason.EXTERNAL_FILE_IN_USE))
|
sendError(UpdateException(UpdateException.Reason.EXTERNAL_FILE_IN_USE))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -288,7 +483,7 @@ class ReleaseManager private constructor(
|
|||||||
performUpdate(release)
|
performUpdate(release)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updatePreferences(release: Release) {
|
private fun updatePreferences(release: Downloadable) {
|
||||||
val sharedPreferences = PreferenceUtils.get(applicationContext)
|
val sharedPreferences = PreferenceUtils.get(applicationContext)
|
||||||
|
|
||||||
sharedPreferences.setString(
|
sharedPreferences.setString(
|
||||||
@@ -308,8 +503,14 @@ class ReleaseManager private constructor(
|
|||||||
sharedPreferences.setString(PreferenceUtils.Key.CURRENT_RELEASE_MODIFIED, fileHash)
|
sharedPreferences.setString(PreferenceUtils.Key.CURRENT_RELEASE_MODIFIED, fileHash)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun computeFileHash(file: File): String =
|
private fun computeFileHash(file: File): String {
|
||||||
file.source().buffer().readByteString().md5().hex()
|
val sink = HashingSink.md5(blackholeSink())
|
||||||
|
file.source().buffer().use {
|
||||||
|
it.readAll(sink)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sink.hash.hex()
|
||||||
|
}
|
||||||
|
|
||||||
private fun getGeodeOutputPath(): File {
|
private fun getGeodeOutputPath(): File {
|
||||||
val geodeName = LaunchUtils.geodeFilename
|
val geodeName = LaunchUtils.geodeFilename
|
||||||
@@ -318,6 +519,40 @@ class ReleaseManager private constructor(
|
|||||||
return File(geodeDirectory, geodeName)
|
return File(geodeDirectory, geodeName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun createRandomString(): String {
|
||||||
|
val alphabet = ('A'..'Z') + ('a'..'z') + ('0'..'9')
|
||||||
|
return buildString(8) {
|
||||||
|
repeat(8) { append(alphabet.random()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getTempFile(filename: String): File {
|
||||||
|
val geodeDirectory = LaunchUtils.getBaseDirectory(applicationContext)
|
||||||
|
|
||||||
|
// warning!! while File::createTempFile may look tempting, a certain brand of phones has a messed up implementation of it
|
||||||
|
// so we're making a temp file manually (as long as it doesn't collide with the geode download, it's okay)
|
||||||
|
|
||||||
|
val suffix = createRandomString()
|
||||||
|
val tmpName = "tmp-$suffix.$filename"
|
||||||
|
|
||||||
|
val tempFile = File(geodeDirectory, tmpName)
|
||||||
|
|
||||||
|
return tempFile
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getTempResourcesDirectory(): File {
|
||||||
|
val finalDir = LaunchUtils.getGeodeResourcesDirectory(applicationContext)
|
||||||
|
val suffix = createRandomString()
|
||||||
|
val tempDirName = "tmp-$suffix-geode.loader"
|
||||||
|
|
||||||
|
val parentDir = finalDir.parentFile
|
||||||
|
return if (parentDir != null) {
|
||||||
|
File(finalDir.parentFile, tempDirName)
|
||||||
|
} else {
|
||||||
|
File(tempDirName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cancels the current update job.
|
* Cancels the current update job.
|
||||||
*/
|
*/
|
||||||
@@ -335,10 +570,14 @@ class ReleaseManager private constructor(
|
|||||||
@OptIn(DelicateCoroutinesApi::class)
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
fun checkForUpdates(isManual: Boolean = false): StateFlow<ReleaseManagerState> {
|
fun checkForUpdates(isManual: Boolean = false): StateFlow<ReleaseManagerState> {
|
||||||
if (!isInUpdate) {
|
if (!isInUpdate) {
|
||||||
|
if (isManual || !shouldUseCache()) {
|
||||||
_uiState.value = ReleaseManagerState.InUpdateCheck
|
_uiState.value = ReleaseManagerState.InUpdateCheck
|
||||||
updateJob = GlobalScope.launch {
|
updateJob = GlobalScope.launch {
|
||||||
checkForNewRelease(isManual)
|
checkForNewRelease(isManual)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
afterUseCachedUpdate()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return _uiState.asStateFlow()
|
return _uiState.asStateFlow()
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
package com.geode.launcher.updater
|
package com.geode.launcher.updater
|
||||||
|
|
||||||
import com.geode.launcher.utils.DownloadUtils.executeCoroutine
|
import com.geode.launcher.BuildConfig
|
||||||
import kotlinx.datetime.Clock
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
|
import kotlin.time.Clock
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.Instant
|
import kotlin.time.Instant
|
||||||
import kotlinx.datetime.until
|
import kotlinx.datetime.until
|
||||||
import kotlinx.serialization.ExperimentalSerializationApi
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonNamingStrategy
|
import kotlinx.serialization.json.JsonNamingStrategy
|
||||||
import kotlinx.serialization.json.okio.decodeFromBufferedSource
|
import kotlinx.serialization.json.okio.decodeFromBufferedSource
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import okhttp3.OkHttp
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
import okhttp3.coroutines.executeAsync
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
|
import kotlin.time.ExperimentalTime
|
||||||
|
|
||||||
class ReleaseRepository(private val httpClient: OkHttpClient) {
|
class ReleaseRepository(private val httpClient: OkHttpClient) {
|
||||||
companion object {
|
companion object {
|
||||||
@@ -22,32 +27,36 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
|
|||||||
|
|
||||||
private const val GITHUB_RATELIMIT_REMAINING = "x-ratelimit-remaining"
|
private const val GITHUB_RATELIMIT_REMAINING = "x-ratelimit-remaining"
|
||||||
private const val GITHUB_RATELIMIT_RESET = "x-ratelimit-reset"
|
private const val GITHUB_RATELIMIT_RESET = "x-ratelimit-reset"
|
||||||
|
|
||||||
|
private const val GEODE_API_BASE = "https://api.geode-sdk.org/v1"
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getLatestLauncherRelease(): Release? {
|
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 releasePath = "$GITHUB_API_BASE/repos/geode-sdk/android-launcher/releases/latest"
|
||||||
|
|
||||||
val url = URL(releasePath)
|
val url = URL(releasePath)
|
||||||
|
|
||||||
return getReleaseByUrl(url)
|
return getReleaseByUrl(url)?.let(::DownloadableLauncherRelease)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getLatestGeodeRelease(): Release? {
|
suspend fun getLatestGeodeRelease(): DownloadableGitHubLoaderRelease? {
|
||||||
val releasePath = "$GITHUB_API_BASE/repos/geode-sdk/geode/releases/latest"
|
val releasePath = "$GITHUB_API_BASE/repos/geode-sdk/geode/releases/latest"
|
||||||
val url = URL(releasePath)
|
val url = URL(releasePath)
|
||||||
|
|
||||||
return getReleaseByUrl(url)
|
return getReleaseByUrl(url)?.let(::DownloadableGitHubLoaderRelease)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getReleaseByTag(tag: String): Release? {
|
suspend fun getReleaseByTag(tag: String): DownloadableGitHubLoaderRelease? {
|
||||||
val releasePath = "$GITHUB_API_BASE/repos/geode-sdk/geode/releases/tags/$tag"
|
val releasePath = "$GITHUB_API_BASE/repos/geode-sdk/geode/releases/tags/$tag"
|
||||||
val url = URL(releasePath)
|
val url = URL(releasePath)
|
||||||
|
|
||||||
return getReleaseByUrl(url)
|
return getReleaseByUrl(url)?.let(::DownloadableGitHubLoaderRelease)
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalSerializationApi::class)
|
@OptIn(ExperimentalSerializationApi::class, ExperimentalTime::class)
|
||||||
suspend fun getLatestGeodePreRelease(): Release? {
|
suspend fun getLatestGeodePreRelease(): DownloadableGitHubLoaderRelease? {
|
||||||
val releasesUrl = "$GITHUB_API_BASE/repos/geode-sdk/geode/releases?per_page=2"
|
val releasesUrl = "$GITHUB_API_BASE/repos/geode-sdk/geode/releases?per_page=2"
|
||||||
val url = URL(releasesUrl)
|
val url = URL(releasesUrl)
|
||||||
|
|
||||||
@@ -55,12 +64,13 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
|
|||||||
.url(url)
|
.url(url)
|
||||||
.addHeader("Accept", "application/json")
|
.addHeader("Accept", "application/json")
|
||||||
.addHeader(GITHUB_API_HEADER, GITHUB_API_VERSION)
|
.addHeader(GITHUB_API_HEADER, GITHUB_API_VERSION)
|
||||||
|
.addHeader("User-Agent", userAgent)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val call = httpClient.newCall(request)
|
val call = httpClient.newCall(request)
|
||||||
val response = call.executeCoroutine()
|
|
||||||
|
|
||||||
return when (response.code) {
|
return call.executeAsync().use { response ->
|
||||||
|
when (response.code) {
|
||||||
200 -> {
|
200 -> {
|
||||||
val format = Json {
|
val format = Json {
|
||||||
namingStrategy = JsonNamingStrategy.SnakeCase
|
namingStrategy = JsonNamingStrategy.SnakeCase
|
||||||
@@ -68,10 +78,11 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val release = format.decodeFromBufferedSource<List<Release>>(
|
val release = format.decodeFromBufferedSource<List<Release>>(
|
||||||
response.body!!.source()
|
response.body.source()
|
||||||
)
|
)
|
||||||
|
|
||||||
release.find { it.tagName != "nightly" }
|
(release.find { it.tagName != "nightly" })
|
||||||
|
?.let(::DownloadableGitHubLoaderRelease)
|
||||||
}
|
}
|
||||||
403 -> {
|
403 -> {
|
||||||
// determine if the error code is a ratelimit
|
// determine if the error code is a ratelimit
|
||||||
@@ -89,7 +100,7 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
|
|||||||
throw IOException(msg)
|
throw IOException(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
throw IOException("response 403: ${response.body!!.string()}")
|
throw IOException("response 403: ${response.body.string()}")
|
||||||
}
|
}
|
||||||
404 -> {
|
404 -> {
|
||||||
null
|
null
|
||||||
@@ -99,7 +110,9 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTime::class)
|
||||||
private fun generateRateLimitMessage(resetTime: Instant): String {
|
private fun generateRateLimitMessage(resetTime: Instant): String {
|
||||||
val currentTime = Clock.System.now()
|
val currentTime = Clock.System.now()
|
||||||
val resetDelay = currentTime.until(resetTime, DateTimeUnit.SECOND)
|
val resetDelay = currentTime.until(resetTime, DateTimeUnit.SECOND)
|
||||||
@@ -109,18 +122,18 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
|
|||||||
return "api ratelimit reached, try again in ${formattedWait}"
|
return "api ratelimit reached, try again in ${formattedWait}"
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalSerializationApi::class)
|
@OptIn(ExperimentalSerializationApi::class, ExperimentalTime::class)
|
||||||
private suspend fun getReleaseByUrl(url: URL): Release? {
|
private suspend fun getReleaseByUrl(url: URL): Release? {
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url(url)
|
.url(url)
|
||||||
.addHeader("Accept", "application/json")
|
.addHeader("Accept", "application/json")
|
||||||
.addHeader(GITHUB_API_HEADER, GITHUB_API_VERSION)
|
.addHeader(GITHUB_API_HEADER, GITHUB_API_VERSION)
|
||||||
|
.addHeader("User-Agent", userAgent)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val call = httpClient.newCall(request)
|
val call = httpClient.newCall(request)
|
||||||
val response = call.executeCoroutine()
|
return call.executeAsync().use { response ->
|
||||||
|
when (response.code) {
|
||||||
return when (response.code) {
|
|
||||||
200 -> {
|
200 -> {
|
||||||
val format = Json {
|
val format = Json {
|
||||||
namingStrategy = JsonNamingStrategy.SnakeCase
|
namingStrategy = JsonNamingStrategy.SnakeCase
|
||||||
@@ -128,7 +141,7 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val release = format.decodeFromBufferedSource<Release>(
|
val release = format.decodeFromBufferedSource<Release>(
|
||||||
response.body!!.source()
|
response.body.source()
|
||||||
)
|
)
|
||||||
|
|
||||||
release
|
release
|
||||||
@@ -137,8 +150,8 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
|
|||||||
// determine if the error code is a ratelimit
|
// determine if the error code is a ratelimit
|
||||||
// (github docs say it sends 429 too, but haven't seen that)
|
// (github docs say it sends 429 too, but haven't seen that)
|
||||||
|
|
||||||
val limitRemaining = response.headers.get(GITHUB_RATELIMIT_REMAINING)?.toInt()
|
val limitRemaining = response.headers[GITHUB_RATELIMIT_REMAINING]?.toInt()
|
||||||
val limitReset = response.headers.get(GITHUB_RATELIMIT_RESET)?.toLong()
|
val limitReset = response.headers[GITHUB_RATELIMIT_RESET]?.toLong()
|
||||||
|
|
||||||
if (limitRemaining == 0 && limitReset != null) {
|
if (limitRemaining == 0 && limitReset != null) {
|
||||||
// handle ratelimit with a custom error
|
// handle ratelimit with a custom error
|
||||||
@@ -149,7 +162,7 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
|
|||||||
throw IOException(msg)
|
throw IOException(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
throw IOException("response 403: ${response.body!!.string()}")
|
throw IOException("response 403: ${response.body.string()}")
|
||||||
}
|
}
|
||||||
404 -> {
|
404 -> {
|
||||||
null
|
null
|
||||||
@@ -160,3 +173,46 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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,6 +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.BuildConfig
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -30,7 +31,7 @@ class ReleaseViewModel(private val application: Application): ViewModel() {
|
|||||||
sealed class ReleaseUIState {
|
sealed class ReleaseUIState {
|
||||||
data object InUpdateCheck : ReleaseUIState()
|
data object InUpdateCheck : ReleaseUIState()
|
||||||
data class Failure(val exception: Exception) : ReleaseUIState()
|
data class Failure(val exception: Exception) : ReleaseUIState()
|
||||||
data class InDownload(val downloaded: Long, val outOf: Long) : ReleaseUIState()
|
data class InDownload(val downloaded: Long, val outOf: Long?) : ReleaseUIState()
|
||||||
data class Finished(val hasUpdated: Boolean = false) : ReleaseUIState()
|
data class Finished(val hasUpdated: Boolean = false) : ReleaseUIState()
|
||||||
data class Cancelled(val isCancelling: Boolean = false) : ReleaseUIState()
|
data class Cancelled(val isCancelling: Boolean = false) : ReleaseUIState()
|
||||||
|
|
||||||
@@ -53,7 +54,7 @@ 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).availableLauncherUpdate
|
val nextLauncherUpdate = ReleaseManager.get(application).availableLauncherUpdateTag
|
||||||
|
|
||||||
var hasPerformedCheck = false
|
var hasPerformedCheck = false
|
||||||
private set
|
private set
|
||||||
@@ -89,6 +90,10 @@ class ReleaseViewModel(private val application: Application): ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun runReleaseCheck(isManual: Boolean = false) {
|
fun runReleaseCheck(isManual: Boolean = false) {
|
||||||
|
if (BuildConfig.GOOGLE_PLAY_BUILD) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
hasPerformedCheck = true
|
hasPerformedCheck = true
|
||||||
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
|||||||
@@ -13,8 +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}"
|
||||||
|
|
||||||
|
// anything below this version code is blocked from opening the game
|
||||||
const val SUPPORTED_VERSION_CODE_MIN = 37L
|
const val SUPPORTED_VERSION_CODE_MIN = 37L
|
||||||
const val SUPPORTED_VERSION_CODE = 39L
|
|
||||||
|
|
||||||
const val SUPPORTED_VERSION_STRING = "2.2.142"
|
// anything below this version code shows a warning on the main menu
|
||||||
|
const val SUPPORTED_VERSION_CODE_MIN_WARNING = 41L
|
||||||
|
|
||||||
|
// anything above this version code will show a warning when starting the game
|
||||||
|
const val SUPPORTED_VERSION_CODE = 41L
|
||||||
|
|
||||||
|
const val SUPPORTED_VERSION_STRING = "2.2.144"
|
||||||
}
|
}
|
||||||
@@ -2,26 +2,71 @@ package com.geode.launcher.utils
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
|
import kotlin.math.ceil
|
||||||
|
import kotlin.math.floor
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
class ConstrainedFrameLayout(context: Context) : FrameLayout(context) {
|
class ConstrainedFrameLayout(context: Context) : FrameLayout(context) {
|
||||||
// avoid running the aspect ratio fix when we don't need it
|
// avoid running the aspect ratio fix when we don't need it
|
||||||
var aspectRatio: Float? = null;
|
var aspectRatio: Float? = null
|
||||||
|
var zoom: Float? = null
|
||||||
|
var fitZoom: Boolean = false
|
||||||
|
|
||||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||||
if (aspectRatio != null) {
|
var paddingX = 0.0f
|
||||||
|
var paddingY = 0.0f
|
||||||
|
|
||||||
|
val currentZoom = zoom
|
||||||
|
if (currentZoom != null) {
|
||||||
val width = MeasureSpec.getSize(widthMeasureSpec)
|
val width = MeasureSpec.getSize(widthMeasureSpec)
|
||||||
val height = MeasureSpec.getSize(heightMeasureSpec)
|
val height = MeasureSpec.getSize(heightMeasureSpec)
|
||||||
if (width == 0 || height == 0) {
|
if (width == 0 || height == 0) {
|
||||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val calculatedWidth = height * (aspectRatio ?: 1.0f);
|
val calculatedWidth = width * currentZoom
|
||||||
val padding = ((width - calculatedWidth) / 2).toInt()
|
val calculatedHeight = height * currentZoom
|
||||||
|
|
||||||
// ignore paddings where the screen ends up looking odd
|
paddingX = (width - calculatedWidth)/2
|
||||||
if (padding > 0) {
|
paddingY = (height - calculatedHeight)/2
|
||||||
setPadding(padding, 0, padding, 0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
super.onMeasure(
|
||||||
|
|||||||
@@ -2,37 +2,63 @@ package com.geode.launcher.utils
|
|||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.FileUtils
|
import android.os.FileUtils
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.runInterruptible
|
import kotlinx.coroutines.runInterruptible
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.Call
|
|
||||||
import okhttp3.Callback
|
|
||||||
import okhttp3.MediaType
|
import okhttp3.MediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.RequestBody
|
||||||
import okhttp3.ResponseBody
|
import okhttp3.ResponseBody
|
||||||
|
import okhttp3.coroutines.executeAsync
|
||||||
import okio.Buffer
|
import okio.Buffer
|
||||||
|
import okio.BufferedSink
|
||||||
import okio.BufferedSource
|
import okio.BufferedSource
|
||||||
|
import okio.FileSystem
|
||||||
|
import okio.ForwardingSink
|
||||||
import okio.ForwardingSource
|
import okio.ForwardingSource
|
||||||
|
import okio.HashingSink
|
||||||
|
import okio.Path.Companion.toOkioPath
|
||||||
|
import okio.Sink
|
||||||
import okio.Source
|
import okio.Source
|
||||||
import okio.buffer
|
import okio.buffer
|
||||||
|
import okio.sink
|
||||||
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import java.util.zip.ZipInputStream
|
import java.util.zip.ZipInputStream
|
||||||
import kotlin.coroutines.resume
|
|
||||||
import kotlin.coroutines.resumeWithException
|
|
||||||
|
|
||||||
|
|
||||||
typealias ProgressCallback = (progress: Long, outOf: Long) -> Unit
|
typealias ProgressCallback = (progress: Long, outOf: Long) -> Unit
|
||||||
|
|
||||||
object DownloadUtils {
|
object DownloadUtils {
|
||||||
|
suspend fun downloadFile(
|
||||||
|
httpClient: OkHttpClient,
|
||||||
|
url: String,
|
||||||
|
outputFile: File,
|
||||||
|
onProgress: ProgressCallback? = null,
|
||||||
|
): String = downloadStream(
|
||||||
|
httpClient,
|
||||||
|
url,
|
||||||
|
onProgress = onProgress
|
||||||
|
).use { body ->
|
||||||
|
val hashingSink = HashingSink.sha256(outputFile.sink().buffer())
|
||||||
|
hashingSink.use { sink ->
|
||||||
|
body.source().use { source ->
|
||||||
|
source.readAll(sink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hashingSink.hash.hex()
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun downloadStream(
|
suspend fun downloadStream(
|
||||||
httpClient: OkHttpClient,
|
httpClient: OkHttpClient,
|
||||||
url: String,
|
url: String,
|
||||||
onProgress: ProgressCallback? = null
|
onProgress: ProgressCallback? = null,
|
||||||
): InputStream {
|
): ResponseBody {
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url(url)
|
.url(url)
|
||||||
.build()
|
.build()
|
||||||
@@ -50,7 +76,7 @@ object DownloadUtils {
|
|||||||
val originalResponse = chain.proceed(chain.request())
|
val originalResponse = chain.proceed(chain.request())
|
||||||
originalResponse.newBuilder()
|
originalResponse.newBuilder()
|
||||||
.body(ProgressResponseBody(
|
.body(ProgressResponseBody(
|
||||||
originalResponse.body!!, onProgress
|
originalResponse.body, onProgress
|
||||||
))
|
))
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
@@ -59,50 +85,67 @@ object DownloadUtils {
|
|||||||
val progressClient = progressClientBuilder.build()
|
val progressClient = progressClientBuilder.build()
|
||||||
|
|
||||||
val call = progressClient.newCall(request)
|
val call = progressClient.newCall(request)
|
||||||
val response = call.executeCoroutine()
|
val response = call.executeAsync()
|
||||||
|
|
||||||
return response.body!!.byteStream()
|
val responseCode = response.code
|
||||||
|
if (responseCode != 200) {
|
||||||
|
response.close()
|
||||||
|
throw IOException("unexpected response ${response.code}")
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun Call.executeCoroutine(): Response {
|
return response.body
|
||||||
return suspendCancellableCoroutine { continuation ->
|
|
||||||
this.enqueue(object : Callback {
|
|
||||||
override fun onFailure(call: Call, e: IOException) {
|
|
||||||
if (continuation.isCancelled) {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
continuation.resumeWithException(e)
|
suspend fun copyZipStreamToDirectory(inputStream: InputStream, output: File) = runInterruptible {
|
||||||
|
ZipInputStream(inputStream).use { zipStream ->
|
||||||
|
if (output.exists()) output.deleteRecursively()
|
||||||
|
output.mkdirs()
|
||||||
|
|
||||||
|
val canonicalOutput = output.canonicalPath
|
||||||
|
|
||||||
|
var entry = zipStream.nextEntry
|
||||||
|
while (entry != null) {
|
||||||
|
val destination = File(output, entry.name)
|
||||||
|
if (!destination.canonicalPath.startsWith(canonicalOutput)) {
|
||||||
|
throw IOException("attempted copy to outside of output directory: ${entry.name}")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResponse(call: Call, response: Response) {
|
if (destination.isDirectory) {
|
||||||
continuation.resume(response)
|
destination.mkdirs()
|
||||||
}
|
} else {
|
||||||
})
|
destination.parentFile?.mkdirs()
|
||||||
|
|
||||||
continuation.invokeOnCancellation {
|
destination.outputStream().use { destinationStream ->
|
||||||
this.cancel()
|
zipStream.copyTo(destinationStream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry = zipStream.nextEntry
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,6 +156,22 @@ 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) {
|
||||||
@@ -125,6 +184,13 @@ object DownloadUtils {
|
|||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun copyDirectory(sourceFile: File, outputFile: File) = withContext(Dispatchers.IO) {
|
||||||
|
sourceFile.copyRecursively(
|
||||||
|
outputFile,
|
||||||
|
overwrite = true
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// based on <https://github.com/square/okhttp/blob/master/samples/guide/src/main/java/okhttp3/recipes/Progress.java>
|
// based on <https://github.com/square/okhttp/blob/master/samples/guide/src/main/java/okhttp3/recipes/Progress.java>
|
||||||
@@ -167,3 +233,38 @@ 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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import android.os.Environment
|
|||||||
import android.provider.DocumentsContract
|
import android.provider.DocumentsContract
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
|
||||||
class FileUtils {
|
class FileUtils {
|
||||||
// copied from https://stackoverflow.com/questions/17546101/get-real-path-for-uri-android
|
// copied from https://stackoverflow.com/questions/17546101/get-real-path-for-uri-android
|
||||||
@@ -86,7 +87,7 @@ class FileUtils {
|
|||||||
val file = File(id)
|
val file = File(id)
|
||||||
if (file.exists()) return id
|
if (file.exists()) return id
|
||||||
}
|
}
|
||||||
val contentUri = ContentUris.withAppendedId(Uri.parse("content://downloads/public_downloads"), java.lang.Long.valueOf(id))
|
val contentUri = ContentUris.withAppendedId("content://downloads/public_downloads".toUri(), java.lang.Long.valueOf(id))
|
||||||
return getDataColumn(context, contentUri, null, null)
|
return getDataColumn(context, contentUri, null, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ object GamePackageUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun showDownloadBadge(packageManager: PackageManager) =
|
||||||
|
!isGameInstalled(packageManager) || !identifyGameLegitimacy(packageManager)
|
||||||
|
|
||||||
fun getGameVersionCode(packageManager: PackageManager): Long {
|
fun getGameVersionCode(packageManager: PackageManager): Long {
|
||||||
val game = packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0)
|
val game = packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0)
|
||||||
|
|
||||||
@@ -31,15 +34,26 @@ object GamePackageUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getGameVersionCodeOrNull(packageManager: PackageManager): Long? {
|
||||||
|
return try {
|
||||||
|
getGameVersionCode(packageManager)
|
||||||
|
} catch (e: PackageManager.NameNotFoundException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getGameVersionString(packageManager: PackageManager): String {
|
fun getGameVersionString(packageManager: PackageManager): String {
|
||||||
val game = packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0)
|
val game = packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0)
|
||||||
return game.versionName
|
return game.versionName ?: "0.000"
|
||||||
}
|
}
|
||||||
|
|
||||||
private val gameVersionMap = mapOf(
|
private val gameVersionMap = mapOf(
|
||||||
37L to "2.200",
|
37L to "2.200",
|
||||||
38L to "2.205",
|
38L to "2.205",
|
||||||
39L to "2.206"
|
39L to "2.206",
|
||||||
|
40L to "2.2074", // ok
|
||||||
|
41L to "2.2081",
|
||||||
|
42L to "2.209",
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,14 +64,25 @@ object GamePackageUtils {
|
|||||||
return gameVersionMap[versionCode] ?: getGameVersionString(packageManager)
|
return gameVersionMap[versionCode] ?: getGameVersionString(packageManager)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getSupportedUnifiedVersion(): String {
|
||||||
|
return gameVersionMap[Constants.SUPPORTED_VERSION_CODE] ?: Constants.SUPPORTED_VERSION_STRING
|
||||||
|
}
|
||||||
|
|
||||||
fun detectAbiMismatch(context: Context, packageInfo: PackageInfo, loadException: Error): Boolean {
|
fun detectAbiMismatch(context: Context, packageInfo: PackageInfo, loadException: Error): Boolean {
|
||||||
val abi = LaunchUtils.applicationArchitecture
|
val abi = LaunchUtils.applicationArchitecture
|
||||||
val isExtracted =
|
val isExtracted =
|
||||||
packageInfo.applicationInfo.flags and ApplicationInfo.FLAG_EXTRACT_NATIVE_LIBS == ApplicationInfo.FLAG_EXTRACT_NATIVE_LIBS
|
packageInfo.applicationInfo!!.flags and ApplicationInfo.FLAG_EXTRACT_NATIVE_LIBS == ApplicationInfo.FLAG_EXTRACT_NATIVE_LIBS
|
||||||
val isSplit = (packageInfo.applicationInfo.splitSourceDirs?.size ?: 0) > 1
|
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 =
|
val metadata =
|
||||||
"Geometry Dash metadata:\nSplit sources: $isSplit\nExtracted libraries: $isExtracted\nLauncher architecture: $abi"
|
"Geometry Dash metadata:\nSplit sources: $isSplit\nExtracted libraries: $isExtracted\nLauncher architecture: $abi\nGame version: $versionCode"
|
||||||
Log.i("GeodeLauncher", metadata)
|
Log.i("GeodeLauncher", metadata)
|
||||||
|
|
||||||
// easiest check! these messages are hardcoded in bionic/linker/linker_phdr.cpp
|
// easiest check! these messages are hardcoded in bionic/linker/linker_phdr.cpp
|
||||||
@@ -74,7 +99,7 @@ object GamePackageUtils {
|
|||||||
val oppositeArchitecture = if (abi == "arm64-v8a") "armeabi-v7a" else "arm64-v8a"
|
val oppositeArchitecture = if (abi == "arm64-v8a") "armeabi-v7a" else "arm64-v8a"
|
||||||
|
|
||||||
// detect issues using native library directory (only works on extracted libraries)
|
// detect issues using native library directory (only works on extracted libraries)
|
||||||
val nativeLibraryDir = packageInfo.applicationInfo.nativeLibraryDir
|
val nativeLibraryDir = packageInfo.applicationInfo!!.nativeLibraryDir
|
||||||
|
|
||||||
if (nativeLibraryDir.contains(oppositeArchitecture)) {
|
if (nativeLibraryDir.contains(oppositeArchitecture)) {
|
||||||
return true
|
return true
|
||||||
@@ -105,31 +130,47 @@ object GamePackageUtils {
|
|||||||
val clazz = assetManager.javaClass
|
val clazz = assetManager.javaClass
|
||||||
val aspMethod = clazz.getDeclaredMethod("addAssetPath", String::class.java)
|
val aspMethod = clazz.getDeclaredMethod("addAssetPath", String::class.java)
|
||||||
|
|
||||||
aspMethod.invoke(assetManager, packageInfo.applicationInfo.sourceDir)
|
aspMethod.invoke(assetManager, packageInfo.applicationInfo?.sourceDir)
|
||||||
packageInfo.applicationInfo.splitSourceDirs?.forEach {
|
packageInfo.applicationInfo?.splitSourceDirs?.forEach {
|
||||||
aspMethod.invoke(assetManager, it)
|
aspMethod.invoke(assetManager, it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val GAME_CERTIFICATE_HASH = "f5e7d8284d72c461a5f022d0cf755df101c3bb1e69cbe241bc1aef2cc5610a43"
|
private const val GAME_CERTIFICATE_HASH = "f5e7d8284d72c461a5f022d0cf755df101c3bb1e69cbe241bc1aef2cc5610a43"
|
||||||
|
private const val AMAZON_CERTIFICATE_HASH = "1f228081e4d66e006887d4ba0f3d40e96a80c758b84231f11cd5c1c9aef6048f"
|
||||||
|
|
||||||
private fun validateCertificate(certificate: ByteArray): Boolean =
|
private fun validateCertificate(certificate: ByteArray): GameSource = when (val hash = certificate.toByteString().sha256().hex()) {
|
||||||
certificate.toByteString().sha256().hex() == GAME_CERTIFICATE_HASH
|
GAME_CERTIFICATE_HASH -> GameSource.GOOGLE
|
||||||
|
AMAZON_CERTIFICATE_HASH -> GameSource.AMAZON
|
||||||
|
else -> {
|
||||||
|
println("Encountered certificate hash: $hash")
|
||||||
|
GameSource.UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun identifyGameLegitimacy(packageManager: PackageManager): Boolean {
|
enum class GameSource {
|
||||||
|
UNKNOWN, GOOGLE, AMAZON
|
||||||
|
}
|
||||||
|
|
||||||
|
fun identifyGameSource(packageManager: PackageManager): GameSource {
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
val certificates = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
val certificates = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
val game = packageManager.getPackageInfo(Constants.PACKAGE_NAME, PackageManager.GET_SIGNING_CERTIFICATES)
|
val game = packageManager.getPackageInfo(Constants.PACKAGE_NAME, PackageManager.GET_SIGNING_CERTIFICATES)
|
||||||
val signingInfo = game.signingInfo
|
val signingInfo = game.signingInfo
|
||||||
|
|
||||||
signingInfo.signingCertificateHistory
|
signingInfo?.signingCertificateHistory ?: return GameSource.UNKNOWN
|
||||||
} else {
|
} else {
|
||||||
val game = packageManager.getPackageInfo(Constants.PACKAGE_NAME, PackageManager.GET_SIGNATURES)
|
val game = packageManager.getPackageInfo(Constants.PACKAGE_NAME, PackageManager.GET_SIGNATURES)
|
||||||
game.signatures
|
game.signatures
|
||||||
}
|
}
|
||||||
|
|
||||||
return certificates.any {
|
return certificates?.map {
|
||||||
validateCertificate(it.toByteArray())
|
validateCertificate(it.toByteArray())
|
||||||
|
}?.firstOrNull {
|
||||||
|
it != GameSource.UNKNOWN
|
||||||
|
} ?: GameSource.UNKNOWN
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
fun identifyGameLegitimacy(packageManager: PackageManager) =
|
||||||
|
identifyGameSource(packageManager) != GameSource.UNKNOWN
|
||||||
}
|
}
|
||||||
@@ -7,22 +7,31 @@ import android.content.ClipboardManager
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.hardware.lights.Light
|
||||||
|
import android.hardware.lights.LightState
|
||||||
|
import android.hardware.lights.LightsRequest
|
||||||
|
import android.net.ConnectivityManager
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
|
import android.os.VibrationEffect
|
||||||
|
import android.os.Vibrator
|
||||||
|
import android.os.VibratorManager
|
||||||
import android.provider.DocumentsContract
|
import android.provider.DocumentsContract
|
||||||
import android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION
|
import android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import android.view.InputDevice
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.result.ActivityResultLauncher
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.annotation.Keep
|
import androidx.annotation.Keep
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.browser.customtabs.CustomTabsIntent
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.net.toUri
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import com.geode.launcher.BuildConfig
|
import com.geode.launcher.BuildConfig
|
||||||
import com.geode.launcher.R
|
import com.geode.launcher.R
|
||||||
import com.geode.launcher.UserDirectoryProvider
|
|
||||||
import com.geode.launcher.activityresult.GeodeOpenFileActivityResult
|
import com.geode.launcher.activityresult.GeodeOpenFileActivityResult
|
||||||
import com.geode.launcher.activityresult.GeodeOpenFilesActivityResult
|
import com.geode.launcher.activityresult.GeodeOpenFilesActivityResult
|
||||||
import com.geode.launcher.activityresult.GeodeSaveFileActivityResult
|
import com.geode.launcher.activityresult.GeodeSaveFileActivityResult
|
||||||
@@ -34,6 +43,8 @@ import kotlin.system.exitProcess
|
|||||||
@Suppress("unused", "KotlinJniMissingFunction")
|
@Suppress("unused", "KotlinJniMissingFunction")
|
||||||
object GeodeUtils {
|
object GeodeUtils {
|
||||||
private lateinit var activity: WeakReference<AppCompatActivity>
|
private lateinit var activity: WeakReference<AppCompatActivity>
|
||||||
|
var handleSafeArea: Boolean = false
|
||||||
|
|
||||||
private lateinit var openFileResultLauncher: ActivityResultLauncher<GeodeOpenFileActivityResult.OpenFileParams>
|
private lateinit var openFileResultLauncher: ActivityResultLauncher<GeodeOpenFileActivityResult.OpenFileParams>
|
||||||
private lateinit var openDirectoryResultLauncher: ActivityResultLauncher<Uri?>
|
private lateinit var openDirectoryResultLauncher: ActivityResultLauncher<Uri?>
|
||||||
private lateinit var openFilesResultLauncher: ActivityResultLauncher<GeodeOpenFilesActivityResult.OpenFileParams>
|
private lateinit var openFilesResultLauncher: ActivityResultLauncher<GeodeOpenFilesActivityResult.OpenFileParams>
|
||||||
@@ -172,7 +183,7 @@ object GeodeUtils {
|
|||||||
val manager = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
val manager = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
val clip = manager.primaryClip
|
val clip = manager.primaryClip
|
||||||
if (clip != null && clip.itemCount > 0) {
|
if (clip != null && clip.itemCount > 0) {
|
||||||
return clip.getItemAt(0).text.toString()
|
return clip.getItemAt(0).coerceToText(this).toString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
@@ -180,10 +191,16 @@ object GeodeUtils {
|
|||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun restartGame() {
|
fun restartGame() {
|
||||||
|
restartGame(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun restartGame(safeMode: Boolean) {
|
||||||
activity.get()?.run {
|
activity.get()?.run {
|
||||||
packageManager.getLaunchIntentForPackage(packageName)?.also {
|
packageManager.getLaunchIntentForPackage(packageName)?.also {
|
||||||
val mainIntent = Intent.makeRestartActivityTask(it.component)
|
val mainIntent = Intent.makeRestartActivityTask(it.component)
|
||||||
mainIntent.putExtra("restarted", true)
|
mainIntent.putExtra("restarted", true)
|
||||||
|
mainIntent.putExtra("safe-mode", safeMode)
|
||||||
startActivity(mainIntent)
|
startActivity(mainIntent)
|
||||||
exitProcess(0)
|
exitProcess(0)
|
||||||
}
|
}
|
||||||
@@ -196,24 +213,22 @@ object GeodeUtils {
|
|||||||
val context = activity.get()!!
|
val context = activity.get()!!
|
||||||
|
|
||||||
val pathFile = File(path)
|
val pathFile = File(path)
|
||||||
val baseDirectory = LaunchUtils.getBaseDirectory(context)
|
val baseDirectory = LaunchUtils.getBaseDirectory(context, true)
|
||||||
val isInternalPath = pathFile.startsWith(baseDirectory)
|
val isInternalPath = pathFile.startsWith(baseDirectory)
|
||||||
|
|
||||||
val intent = if (isInternalPath) {
|
val intent = if (isInternalPath) {
|
||||||
// TODO: figure out how to get this to point to the path it should be pointing at
|
val relativePath = pathFile.relativeTo(baseDirectory)
|
||||||
// (the best i got was pointing at a file)
|
val uri = DocumentsContract.buildDocumentUri(
|
||||||
// val relativePath = pathFile.relativeTo(baseDirectory)
|
"com.android.externalstorage.documents",
|
||||||
|
if (BuildConfig.GOOGLE_PLAY_BUILD) {
|
||||||
|
"primary:Android/data/${context.packageName}/$relativePath"
|
||||||
|
} else {
|
||||||
|
"primary:Android/media/${context.packageName}/$relativePath"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
Intent(Intent.ACTION_VIEW).apply {
|
Intent(Intent.ACTION_VIEW).apply {
|
||||||
data = DocumentsContract.buildRootUri(
|
setDataAndType(uri, DocumentsContract.Document.MIME_TYPE_DIR)
|
||||||
"${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
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||||
@@ -373,11 +388,6 @@ object GeodeUtils {
|
|||||||
return activity.filesDir.canonicalPath
|
return activity.filesDir.canonicalPath
|
||||||
}
|
}
|
||||||
|
|
||||||
private val gameVersionMap = mapOf(
|
|
||||||
37L to "2.200",
|
|
||||||
38L to "2.205"
|
|
||||||
)
|
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun getGameVersion(): String {
|
fun getGameVersion(): String {
|
||||||
// these versions should be aligned to windows releases, not what android says
|
// these versions should be aligned to windows releases, not what android says
|
||||||
@@ -388,8 +398,25 @@ object GeodeUtils {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun getGameVersionCode(): Long {
|
||||||
|
activity.get()?.run {
|
||||||
|
return GamePackageUtils.getGameVersionCode(packageManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current version code of the launcher.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun getLauncherVersion(): Int {
|
||||||
|
return BuildConfig.VERSION_CODE
|
||||||
|
}
|
||||||
|
|
||||||
fun isGeodeUri(uri: Uri): Boolean {
|
fun isGeodeUri(uri: Uri): Boolean {
|
||||||
return "com.geode.launcher.user" == uri.authority
|
return "${BuildConfig.APPLICATION_ID}.user" == uri.authority
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val INTERNAL_PERMISSION_PREFIX = "geode.permission_internal"
|
private const val INTERNAL_PERMISSION_PREFIX = "geode.permission_internal"
|
||||||
@@ -408,7 +435,7 @@ object GeodeUtils {
|
|||||||
Manifest.permission.WRITE_EXTERNAL_STORAGE
|
Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||||
)
|
)
|
||||||
|
|
||||||
return permissions.all {
|
permissions.all {
|
||||||
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
|
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -443,6 +470,68 @@ object GeodeUtils {
|
|||||||
|
|
||||||
private external fun permissionCallback(granted: Boolean)
|
private external fun permissionCallback(granted: Boolean)
|
||||||
|
|
||||||
|
internal fun getVibrator(): Vibrator? = activity.get()?.run {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
val manager = getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
|
||||||
|
manager.defaultVibrator
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// vibration
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If vibration is supported on the device.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun vibrateSupported(): Boolean = getVibrator()?.hasVibrator() == true
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vibrates for the given amount of time.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun vibrate(ms: Long) {
|
||||||
|
if (ms == 0L) {
|
||||||
|
getVibrator()?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
getVibrator()?.vibrate(VibrationEffect.createOneShot(ms, VibrationEffect.DEFAULT_AMPLITUDE))
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
getVibrator()?.vibrate(ms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vibrates with the given pattern.
|
||||||
|
* @see android.os.VibrationEffect.createWaveform
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun vibratePattern(pattern: LongArray, repeat: Int) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
getVibrator()?.vibrate(VibrationEffect.createWaveform(pattern, repeat))
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
getVibrator()?.vibrate(pattern, repeat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun getScreenInsets(): IntArray? {
|
||||||
|
if (!handleSafeArea || Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return activity.get()?.run {
|
||||||
|
val displayCutout = window.decorView.rootWindowInsets?.displayCutout ?: return null
|
||||||
|
|
||||||
|
intArrayOf(displayCutout.safeInsetLeft, displayCutout.safeInsetBottom, displayCutout.safeInsetRight, displayCutout.safeInsetTop)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const val ARGUMENT_SAFE_MODE = "--geode:safe-mode"
|
const val ARGUMENT_SAFE_MODE = "--geode:safe-mode"
|
||||||
|
|
||||||
private var additionalLaunchArguments = arrayListOf<String>()
|
private var additionalLaunchArguments = arrayListOf<String>()
|
||||||
@@ -474,6 +563,8 @@ object GeodeUtils {
|
|||||||
|
|
||||||
const val CAPABILITY_EXTENDED_INPUT = "extended_input"
|
const val CAPABILITY_EXTENDED_INPUT = "extended_input"
|
||||||
const val CAPABILITY_TIMESTAMP_INPUT = "timestamp_inputs"
|
const val CAPABILITY_TIMESTAMP_INPUT = "timestamp_inputs"
|
||||||
|
const val CAPABILITY_RICH_INPUT = "internal_callbacks_v2"
|
||||||
|
const val CAPABILITY_RESIZE_CALLBACK = "resize_callbacks"
|
||||||
|
|
||||||
private var capabilityListener: WeakReference<CapabilityListener?> = WeakReference(null)
|
private var capabilityListener: WeakReference<CapabilityListener?> = WeakReference(null)
|
||||||
|
|
||||||
@@ -481,13 +572,213 @@ object GeodeUtils {
|
|||||||
capabilityListener = WeakReference(listener)
|
capabilityListener = WeakReference(listener)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reports that the game client supports a feature.
|
||||||
|
* These features typically require callbacks into the game, so ensure that the required methods are registered first.
|
||||||
|
*/
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun reportPlatformCapability(capability: String?): Boolean {
|
fun reportPlatformCapability(capability: String?): Boolean {
|
||||||
if (capability.isNullOrEmpty()) {
|
if (capability.isNullOrEmpty()) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return capabilityListener.get()?.onCapabilityAdded(capability) ?: false
|
return capabilityListener.get()?.onCapabilityAdded(capability) == true
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun openWebview(url: String) {
|
||||||
|
val activity = activity.get() ?: return
|
||||||
|
CustomTabsIntent.Builder()
|
||||||
|
.setShowTitle(true)
|
||||||
|
.build()
|
||||||
|
.launchUrl(activity, url.toUri())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if any gamepad or joystick input devices are connected.
|
||||||
|
* This is useful during start, but tracking inputDeviceAdded events is better during runtime.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun controllersConnected(): Int {
|
||||||
|
val deviceIds = InputDevice.getDeviceIds()
|
||||||
|
return deviceIds.count {
|
||||||
|
InputDevice.getDevice(it)?.run {
|
||||||
|
sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD || sources and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
|
||||||
|
} ?: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun getConnectedDevices(): IntArray = InputDevice.getDeviceIds()
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun getDevice(deviceId: Int): InputDevice? = InputDevice.getDevice(deviceId)
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun getDeviceBatteryCapacity(deviceId: Int): Float {
|
||||||
|
val device = InputDevice.getDevice(deviceId) ?: return 0.0f
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
device.batteryState.capacity
|
||||||
|
} else {
|
||||||
|
0.0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun deviceHasBattery(deviceId: Int): Boolean {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val device = InputDevice.getDevice(deviceId) ?: return false
|
||||||
|
return device.batteryState.isPresent
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun getDeviceBatteryStatus(deviceId: Int): Int {
|
||||||
|
val device = InputDevice.getDevice(deviceId) ?: return 0
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
device.batteryState.status
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun getDeviceLightsCount(deviceId: Int): Int {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
val device = InputDevice.getDevice(deviceId) ?: return 0
|
||||||
|
val lightsManager = device.lightsManager
|
||||||
|
|
||||||
|
return lightsManager.lights.size
|
||||||
|
}
|
||||||
|
|
||||||
|
// this is based on paddleboat's api
|
||||||
|
@JvmStatic
|
||||||
|
fun getLightType(deviceId: Int): Int {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
val device = InputDevice.getDevice(deviceId) ?: return 0
|
||||||
|
val lightsManager = device.lightsManager
|
||||||
|
|
||||||
|
var flags = 0
|
||||||
|
lightsManager.lights.forEach { light ->
|
||||||
|
if (light.type == Light.LIGHT_TYPE_PLAYER_ID) {
|
||||||
|
flags = flags or 1
|
||||||
|
} else if (light.hasRgbControl()) {
|
||||||
|
flags = flags or 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return flags
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the color for a device.
|
||||||
|
* @return false if the given device does not exist or setting lights was otherwise unsuccessful
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun setDeviceLightColor(deviceId: Int, color: Int, type: Int): Boolean {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val device = InputDevice.getDevice(deviceId) ?: return false
|
||||||
|
val lightsManager = device.lightsManager
|
||||||
|
|
||||||
|
val lights = lightsManager.lights
|
||||||
|
if (lights.isEmpty()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val actedLights = lights.count { light ->
|
||||||
|
val state = LightState.Builder()
|
||||||
|
|
||||||
|
if (light.type == Light.LIGHT_TYPE_PLAYER_ID && (type and 1 == 1)) {
|
||||||
|
state.setPlayerId(color)
|
||||||
|
} else if (light.hasRgbControl() && (type and 2 == 2)) {
|
||||||
|
state.setColor(color)
|
||||||
|
} else {
|
||||||
|
return@count false
|
||||||
|
}
|
||||||
|
|
||||||
|
val request = LightsRequest.Builder()
|
||||||
|
.addLight(light, state.build())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
lightsManager.openSession()
|
||||||
|
.requestLights(request)
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
return actedLights > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun getDeviceHapticsCount(deviceId: Int): Int {
|
||||||
|
val device = InputDevice.getDevice(deviceId) ?: return 0
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
||||||
|
return if (device.vibrator.hasVibrator()) 1 else 0
|
||||||
|
}
|
||||||
|
|
||||||
|
val vibratorManager = device.vibratorManager
|
||||||
|
val vibratorIds = vibratorManager.vibratorIds
|
||||||
|
return vibratorIds.size
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun performVibration(vibrator: Vibrator?, durationMs: Long, intensity: Int) {
|
||||||
|
vibrator?.apply {
|
||||||
|
if (intensity == 0) {
|
||||||
|
cancel()
|
||||||
|
} else {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val vibrationEffect = VibrationEffect.createOneShot(durationMs, intensity)
|
||||||
|
vibrate(vibrationEffect)
|
||||||
|
} else {
|
||||||
|
vibrate(durationMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vibrates a given device. Set motorIdx == -1 to vibrate all available motors on a device.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun vibrateDevice(deviceId: Int, durationMs: Long, intensity: Int, motorIdx: Int): Boolean {
|
||||||
|
val device = InputDevice.getDevice(deviceId) ?: return false
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
val vibratorManager = device.vibratorManager
|
||||||
|
val vibratorIds = vibratorManager.vibratorIds
|
||||||
|
|
||||||
|
if (motorIdx == -1) {
|
||||||
|
vibratorIds.forEach {
|
||||||
|
val vibrator = vibratorManager.getVibrator(it)
|
||||||
|
performVibration(vibrator, durationMs, intensity)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val deviceId = vibratorIds.getOrNull(motorIdx) ?: return false
|
||||||
|
val vibrator = vibratorManager.getVibrator(deviceId)
|
||||||
|
performVibration(vibrator, durationMs, intensity)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val vibrator = device.vibrator
|
||||||
|
performVibration(vibrator, durationMs, intensity)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun getConnectivityManager(): ConnectivityManager? = activity.get()?.run {
|
||||||
|
getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
}
|
}
|
||||||
|
|
||||||
external fun nativeKeyUp(keyCode: Int, modifiers: Int)
|
external fun nativeKeyUp(keyCode: Int, modifiers: Int)
|
||||||
@@ -495,6 +786,24 @@ object GeodeUtils {
|
|||||||
external fun nativeActionScroll(scrollX: Float, scrollY: Float)
|
external fun nativeActionScroll(scrollX: Float, scrollY: Float)
|
||||||
external fun resizeSurface(width: Int, height: Int)
|
external fun resizeSurface(width: Int, height: Int)
|
||||||
|
|
||||||
// represents the timestamp of the next input callback, in nanoseconds (most events don't send it, but it's there)
|
/**
|
||||||
|
* Gives the timestamp of the next input callback, in nanoseconds.
|
||||||
|
* Enable this by sending the "timestamp_inputs" platform capability.
|
||||||
|
* @see reportPlatformCapability
|
||||||
|
*/
|
||||||
external fun setNextInputTimestamp(timestamp: Long)
|
external fun setNextInputTimestamp(timestamp: Long)
|
||||||
|
|
||||||
|
const val TOUCH_TYPE_BEGIN = 0
|
||||||
|
const val TOUCH_TYPE_MOVE = 1
|
||||||
|
const val TOUCH_TYPE_END = 2
|
||||||
|
const val TOUCH_TYPE_CANCEL = 3
|
||||||
|
|
||||||
|
external fun internalKeyEvent(timestamp: Long, deviceId: Int, eventSource: Int, keyCode: Int, modifiers: Int, isDown: Boolean, repeatCount: Int)
|
||||||
|
external fun internalTouchEvent(timestamp: Long, deviceId: Int, eventSource: Int, eventType: Int, ids: IntArray, xs: FloatArray, ys: FloatArray)
|
||||||
|
external fun internalScrollEvent(timestamp: Long, deviceId: Int, eventSource: Int, scrollX: Float, scrollY: Float, mouseX: Float, mouseY: Float)
|
||||||
|
external fun internalJoystickEvent(timestamp: Long, deviceId: Int, eventSource: Int, leftX: FloatArray, leftY: FloatArray, rightX: FloatArray, rightY: FloatArray, hatX: FloatArray, hatY: FloatArray, leftTrigger: FloatArray, rightTrigger: FloatArray)
|
||||||
|
|
||||||
|
external fun inputDeviceAdded(deviceId: Int)
|
||||||
|
external fun inputDeviceChanged(deviceId: Int)
|
||||||
|
external fun inputDeviceRemoved(deviceId: Int)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package com.geode.launcher.utils
|
||||||
|
|
||||||
|
import android.graphics.drawable.AdaptiveIconDrawable
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||||
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalResources
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.core.content.res.ResourcesCompat
|
||||||
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
|
import com.geode.launcher.R
|
||||||
|
|
||||||
|
// https://gist.github.com/tkuenneth/ddf598663f041dc79960cda503d14448?permalink_comment_id=4660486#gistcomment-4660486
|
||||||
|
@Composable
|
||||||
|
fun adaptiveIconPainterResource(@DrawableRes id: Int): Painter {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val res = LocalResources.current
|
||||||
|
val theme = context.theme
|
||||||
|
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val adaptiveIcon = remember(id) {
|
||||||
|
ResourcesCompat.getDrawable(res, id, theme) as? AdaptiveIconDrawable
|
||||||
|
}
|
||||||
|
if (adaptiveIcon != null) {
|
||||||
|
remember(id) {
|
||||||
|
BitmapPainter(adaptiveIcon.toBitmap().asImageBitmap())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
painterResource(id)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
painterResource(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ApplicationIcon {
|
||||||
|
DEFAULT,
|
||||||
|
GEODE,
|
||||||
|
PRIDE,
|
||||||
|
TRANS,
|
||||||
|
GREEN,
|
||||||
|
SAPPHIRE;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromId(id: String) = when (id) {
|
||||||
|
"default" -> DEFAULT
|
||||||
|
"geode" -> GEODE
|
||||||
|
"pride" -> PRIDE
|
||||||
|
"trans" -> TRANS
|
||||||
|
"sapphire" -> SAPPHIRE
|
||||||
|
"green" -> GREEN
|
||||||
|
else -> DEFAULT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toId() = when (this) {
|
||||||
|
PRIDE -> "pride"
|
||||||
|
GEODE -> "geode"
|
||||||
|
DEFAULT -> "default"
|
||||||
|
TRANS -> "trans"
|
||||||
|
SAPPHIRE -> "sapphire"
|
||||||
|
GREEN -> "green"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ApplicationIconDetails(
|
||||||
|
val id: String,
|
||||||
|
val component: String,
|
||||||
|
@param:DrawableRes val iconId: Int,
|
||||||
|
@param:StringRes val nameId: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
object IconUtils {
|
||||||
|
fun getIconDetails(id: ApplicationIcon) = when (id) {
|
||||||
|
ApplicationIcon.DEFAULT -> ApplicationIconDetails(
|
||||||
|
id = id.toId(),
|
||||||
|
component = "MainActivity",
|
||||||
|
iconId = R.mipmap.ic_launcher,
|
||||||
|
nameId = R.string.application_icon_default
|
||||||
|
)
|
||||||
|
ApplicationIcon.GEODE -> ApplicationIconDetails(
|
||||||
|
id = id.toId(),
|
||||||
|
component = "MainActivityGeode",
|
||||||
|
iconId = R.mipmap.ic_launcher_geode,
|
||||||
|
nameId = R.string.application_icon_geode
|
||||||
|
)
|
||||||
|
ApplicationIcon.PRIDE -> ApplicationIconDetails(
|
||||||
|
id = id.toId(),
|
||||||
|
component = "MainActivityPride",
|
||||||
|
iconId = R.mipmap.ic_launcher_pride,
|
||||||
|
nameId = R.string.application_icon_pride
|
||||||
|
)
|
||||||
|
ApplicationIcon.TRANS -> ApplicationIconDetails(
|
||||||
|
id = id.toId(),
|
||||||
|
component = "MainActivityTrans",
|
||||||
|
iconId = R.mipmap.ic_launcher_trans,
|
||||||
|
nameId = R.string.application_icon_trans
|
||||||
|
)
|
||||||
|
ApplicationIcon.SAPPHIRE -> ApplicationIconDetails(
|
||||||
|
id = id.toId(),
|
||||||
|
component = "MainActivitySapphire",
|
||||||
|
iconId = R.mipmap.ic_launcher_sapphire,
|
||||||
|
nameId = R.string.application_icon_sapphire,
|
||||||
|
)
|
||||||
|
ApplicationIcon.GREEN -> ApplicationIconDetails(
|
||||||
|
id = id.toId(),
|
||||||
|
component = "MainActivityGreen",
|
||||||
|
iconId = R.mipmap.ic_launcher_green,
|
||||||
|
nameId = R.string.application_icon_green,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@ package com.geode.launcher.utils
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import com.geode.launcher.BuildConfig
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.io.Serializable
|
||||||
|
|
||||||
object LaunchUtils {
|
object LaunchUtils {
|
||||||
// supposedly CPU_ABI returns the current arch for the running application
|
// supposedly CPU_ABI returns the current arch for the running application
|
||||||
@@ -40,23 +42,38 @@ object LaunchUtils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun isGeodeInstalled(context: Context): Boolean {
|
fun isGeodeInstalled(context: Context): Boolean {
|
||||||
|
@Suppress("SENSELESS_COMPARISON")
|
||||||
|
if (BuildConfig.PREBUNDLED_GEODE != null) return true
|
||||||
|
|
||||||
return getInstalledGeodePath(context) != null
|
return getInstalledGeodePath(context) != null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the directory that Geode/the game should base itself off of.
|
* Returns the directory that Geode/the game should base itself off of.
|
||||||
*/
|
*/
|
||||||
fun getBaseDirectory(context: Context): File {
|
fun getBaseDirectory(context: Context, ignoreProfile: Boolean = false): File {
|
||||||
|
val dir = if (BuildConfig.GOOGLE_PLAY_BUILD) {
|
||||||
|
context.getExternalFilesDir(null)!!.parentFile!!
|
||||||
|
} else {
|
||||||
// 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")
|
||||||
val dir = context.externalMediaDirs.first()
|
context.externalMediaDirs.first()
|
||||||
|
}
|
||||||
|
|
||||||
// prevent having resources added to system gallery
|
// prevent having resources added to system gallery
|
||||||
// accessing this file every time the directory is read may be a little wasteful...
|
// accessing this file every time the directory is read may be a little wasteful...
|
||||||
val noMediaPath = File(dir, ".nomedia")
|
val noMediaPath = File(dir, ".nomedia")
|
||||||
noMediaPath.createNewFile()
|
noMediaPath.createNewFile()
|
||||||
|
|
||||||
|
val currentProfile = ProfileManager.get(context).getCurrentProfile()
|
||||||
|
if (currentProfile != null && !ignoreProfile) {
|
||||||
|
val profile = File(dir, "profiles/$currentProfile/")
|
||||||
|
profile.mkdirs()
|
||||||
|
|
||||||
|
return profile
|
||||||
|
}
|
||||||
|
|
||||||
return dir
|
return dir
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,13 +81,23 @@ object LaunchUtils {
|
|||||||
return File(getBaseDirectory(context), "save")
|
return File(getBaseDirectory(context), "save")
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val CRASH_INDICATOR_NAME = "lastSessionDidCrash"
|
val CRASH_INDICATOR_NAMES = setOf("lastSessionDidCrash", "last-pid", "last-crashed")
|
||||||
|
|
||||||
private fun getCrashDirectory(context: Context): File {
|
fun getCrashDirectory(context: Context): File {
|
||||||
val base = getBaseDirectory(context)
|
val base = getBaseDirectory(context)
|
||||||
return File(base, "game/geode/crashlogs/")
|
return File(base, "game/geode/crashlogs/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getGeodeLogsDirectory(context: Context): File {
|
||||||
|
val base = getBaseDirectory(context)
|
||||||
|
return File(base, "game/geode/logs/")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getGeodeResourcesDirectory(context: Context): File {
|
||||||
|
val base = getBaseDirectory(context)
|
||||||
|
return File(base, "game/geode/resources/geode.loader/")
|
||||||
|
}
|
||||||
|
|
||||||
fun getLastCrash(context: Context): File? {
|
fun getLastCrash(context: Context): File? {
|
||||||
val crashDirectory = getCrashDirectory(context)
|
val crashDirectory = getCrashDirectory(context)
|
||||||
if (!crashDirectory.exists()) {
|
if (!crashDirectory.exists()) {
|
||||||
@@ -79,7 +106,7 @@ object LaunchUtils {
|
|||||||
|
|
||||||
val children = crashDirectory.listFiles {
|
val children = crashDirectory.listFiles {
|
||||||
// ignore indicator files (including old file)
|
// ignore indicator files (including old file)
|
||||||
_, name -> name != CRASH_INDICATOR_NAME && name != "last-pid"
|
_, name -> !CRASH_INDICATOR_NAMES.contains(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
return children?.maxByOrNull { it.lastModified() }
|
return children?.maxByOrNull { it.lastModified() }
|
||||||
@@ -87,14 +114,15 @@ object LaunchUtils {
|
|||||||
|
|
||||||
fun lastSessionCrashed(context: Context): Boolean {
|
fun lastSessionCrashed(context: Context): Boolean {
|
||||||
val base = getCrashDirectory(context)
|
val base = getCrashDirectory(context)
|
||||||
val crashIndicatorFile = File(base, CRASH_INDICATOR_NAME)
|
return CRASH_INDICATOR_NAMES.any {
|
||||||
|
File(base, it).exists()
|
||||||
return crashIndicatorFile.exists()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class LauncherError {
|
enum class LauncherError : Serializable {
|
||||||
LINKER_NEEDS_64BIT,
|
LINKER_NEEDS_64BIT,
|
||||||
LINKER_NEEDS_32BIT,
|
LINKER_NEEDS_32BIT,
|
||||||
|
LINKER_FAILED_STL,
|
||||||
LINKER_FAILED,
|
LINKER_FAILED,
|
||||||
GENERIC,
|
GENERIC,
|
||||||
CRASHED;
|
CRASHED;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ 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
|
||||||
@@ -20,8 +20,6 @@ import com.geode.launcher.BuildConfig
|
|||||||
*/
|
*/
|
||||||
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(
|
return usePreference(
|
||||||
@@ -70,11 +68,14 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
|
|||||||
preferenceSet: (PreferenceUtils, Key, T) -> Unit
|
preferenceSet: (PreferenceUtils, Key, T) -> Unit
|
||||||
): MutableState<T> {
|
): 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
|
||||||
|
val preferenceValue: MutableState<T> = remember {
|
||||||
val state = mutableStateOf(preferenceGet(preferences, preferenceKey))
|
val state = mutableStateOf(preferenceGet(preferences, preferenceKey))
|
||||||
object : MutableState<T> by state {
|
object : MutableState<T> by state {
|
||||||
override var value: T
|
override var value: T
|
||||||
@@ -113,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,28 +127,34 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
|
|||||||
enum class Key {
|
enum class Key {
|
||||||
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,
|
||||||
LAST_DISMISSED_UPDATE,
|
|
||||||
DISMISSED_GJ_UPDATE,
|
DISMISSED_GJ_UPDATE,
|
||||||
LAUNCH_ARGUMENTS,
|
LAUNCH_ARGUMENTS,
|
||||||
LIMIT_ASPECT_RATIO,
|
LIMIT_ASPECT_RATIO,
|
||||||
DISPLAY_MODE,
|
DISPLAY_MODE,
|
||||||
FORCE_HRR,
|
|
||||||
ENABLE_REDESIGN,
|
|
||||||
RELEASE_CHANNEL_TAG,
|
RELEASE_CHANNEL_TAG,
|
||||||
DEVELOPER_MODE,
|
DEVELOPER_MODE,
|
||||||
CLEANUP_APKS,
|
CLEANUP_APKS,
|
||||||
CUSTOM_SYMBOL_LIST
|
CUSTOM_ASPECT_RATIO,
|
||||||
|
SCREEN_ZOOM,
|
||||||
|
SCREEN_ZOOM_FIT,
|
||||||
|
LIMIT_FRAME_RATE,
|
||||||
|
DISABLE_USER_THEME,
|
||||||
|
SELECTED_ICON,
|
||||||
|
WAIT_PERIOD,
|
||||||
|
LAST_UPDATE_CHECK_TIME,
|
||||||
|
LAST_LAUNCHER_UPDATE,
|
||||||
|
DISABLE_UPDATE_CACHE,
|
||||||
|
PREBUNDLED_GEODE_MIGRATION_PERFORMED,
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun defaultValueForBooleanKey(key: Key): Boolean {
|
private fun defaultValueForBooleanKey(key: Key): Boolean {
|
||||||
return when (key) {
|
return when (key) {
|
||||||
Key.UPDATE_AUTOMATICALLY, Key.FORCE_HRR -> true
|
Key.UPDATE_AUTOMATICALLY -> true
|
||||||
Key.DEVELOPER_MODE -> BuildConfig.DEBUG
|
Key.DEVELOPER_MODE -> BuildConfig.DEBUG
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
@@ -154,31 +162,50 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
|
|||||||
|
|
||||||
private fun defaultValueForIntKey(key: Key) = when (key) {
|
private fun defaultValueForIntKey(key: Key) = when (key) {
|
||||||
Key.DISPLAY_MODE -> if (this.getBoolean(Key.LIMIT_ASPECT_RATIO)) 1 else 0
|
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
|
// people wanted a reset on nightly anyways, so this is a good excuse to do so
|
||||||
else -> 0
|
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_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 -> "PreferenceReleaseModifiedHash"
|
Key.CURRENT_RELEASE_MODIFIED -> "PreferenceReleaseModifiedHash"
|
||||||
Key.LAST_DISMISSED_UPDATE -> "PreferenceLastDismissedUpdate"
|
|
||||||
Key.DISMISSED_GJ_UPDATE -> "PreferenceDismissedGJUpdate"
|
Key.DISMISSED_GJ_UPDATE -> "PreferenceDismissedGJUpdate"
|
||||||
Key.LAUNCH_ARGUMENTS -> "PreferenceLaunchArguments"
|
Key.LAUNCH_ARGUMENTS -> "PreferenceLaunchArguments"
|
||||||
Key.LIMIT_ASPECT_RATIO -> "PreferenceLimitAspectRatio"
|
Key.LIMIT_ASPECT_RATIO -> "PreferenceLimitAspectRatio"
|
||||||
Key.DISPLAY_MODE -> "PreferenceDisplayMode"
|
Key.DISPLAY_MODE -> "PreferenceDisplayMode"
|
||||||
Key.FORCE_HRR -> "PreferenceForceHighRefreshRate"
|
|
||||||
Key.ENABLE_REDESIGN -> "PreferenceEnableRedesign"
|
|
||||||
Key.RELEASE_CHANNEL_TAG -> "PreferenceReleaseChannelTag"
|
Key.RELEASE_CHANNEL_TAG -> "PreferenceReleaseChannelTag"
|
||||||
Key.DEVELOPER_MODE -> "PreferenceDeveloperMode"
|
Key.DEVELOPER_MODE -> "PreferenceDeveloperMode"
|
||||||
Key.CLEANUP_APKS -> "PreferenceCleanupPackages"
|
Key.CLEANUP_APKS -> "PreferenceCleanupPackages"
|
||||||
Key.CUSTOM_SYMBOL_LIST -> "PreferenceCustomSymbolList"
|
Key.CUSTOM_ASPECT_RATIO -> "PreferenceCustomAspectRatio"
|
||||||
|
Key.SCREEN_ZOOM -> "PreferenceScreenZoom"
|
||||||
|
Key.SCREEN_ZOOM_FIT -> "PreferenceScreenZoomFit"
|
||||||
|
Key.LIMIT_FRAME_RATE -> "PreferenceLimitFrameRate"
|
||||||
|
Key.DISABLE_USER_THEME -> "PreferenceDisableUserTheme"
|
||||||
|
Key.SELECTED_ICON -> "PreferenceSelectedIcon"
|
||||||
|
Key.WAIT_PERIOD -> "PreferenceWaitPeriod"
|
||||||
|
Key.LAST_UPDATE_CHECK_TIME -> "PreferenceLastUpdateCheckTime"
|
||||||
|
Key.LAST_LAUNCHER_UPDATE -> "PreferenceLastLauncherUpdate"
|
||||||
|
Key.DISABLE_UPDATE_CACHE -> "PreferenceDisableUpdateCache"
|
||||||
|
Key.PREBUNDLED_GEODE_MIGRATION_PERFORMED -> "PreferencePrebundledGeodeMigrationPerformed"
|
||||||
|
// Key.RELEASE_CHANNEL -> "PreferenceReleaseChannel"
|
||||||
|
// Key.LAST_DISMISSED_UPDATE -> "PreferenceLastDismissedUpdate"
|
||||||
|
// Key.FORCE_HRR -> "PreferenceForceHighRefreshRate"
|
||||||
|
// Key.DISABLE_REDESIGN -> "PreferenceDisableRedesign"
|
||||||
|
// Key.CUSTOM_SYMBOL_LIST -> "PreferenceCustomSymbolList"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,7 +236,7 @@ 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?) {
|
||||||
|
|||||||
@@ -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,22 +1,24 @@
|
|||||||
package com.geode.launcher.utils
|
package com.geode.launcher.utils
|
||||||
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
const val MS_TO_SEC = 1000L
|
const val MS_TO_SEC = 1000L
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun useCountdownTimer(
|
fun useCountdownTimer(
|
||||||
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
|
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
|
||||||
time: Long,
|
time: Duration,
|
||||||
onCountdownFinish: () -> Unit
|
onCountdownFinish: () -> Unit
|
||||||
): Long {
|
): Long {
|
||||||
var millisUntilFinished by remember {
|
var millisUntilFinished by remember {
|
||||||
mutableLongStateOf(time)
|
mutableStateOf(time)
|
||||||
}
|
}
|
||||||
|
|
||||||
var shouldBeCounting by remember {
|
var shouldBeCounting by remember {
|
||||||
@@ -28,9 +30,9 @@ fun useCountdownTimer(
|
|||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
|
|
||||||
if (millisUntilFinished > 0) {
|
if (millisUntilFinished > Duration.ZERO) {
|
||||||
delay(MS_TO_SEC)
|
delay(1.seconds)
|
||||||
millisUntilFinished -= MS_TO_SEC
|
millisUntilFinished -= 1.seconds
|
||||||
} else {
|
} else {
|
||||||
onCountdownFinish()
|
onCountdownFinish()
|
||||||
}
|
}
|
||||||
@@ -58,5 +60,5 @@ fun useCountdownTimer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return millisUntilFinished / MS_TO_SEC
|
return millisUntilFinished.inWholeSeconds
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/****************************************************************************
|
||||||
|
Copyright (c) 2010-2011 cocos2d-x.org
|
||||||
|
|
||||||
|
http://www.cocos2d-x.org
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
****************************************************************************/
|
||||||
|
package org.cocos2dx.lib
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import android.hardware.Sensor
|
||||||
|
import android.hardware.SensorEvent
|
||||||
|
import android.hardware.SensorEventListener
|
||||||
|
import android.hardware.SensorManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.view.Surface
|
||||||
|
import android.view.WindowManager
|
||||||
|
import java.lang.ref.WeakReference
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
class Cocos2dxAccelerometer : SensorEventListener {
|
||||||
|
companion object {
|
||||||
|
@JvmStatic
|
||||||
|
external fun onSensorChanged(pX: Float, pY: Float, pZ: Float, pTimestamp: Long)
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(context: Context) {
|
||||||
|
mContext = WeakReference(context)
|
||||||
|
mSensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
|
||||||
|
mAccelerometer = mSensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
|
||||||
|
val display = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
context.display
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
(context.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay
|
||||||
|
}
|
||||||
|
this.mNaturalOrientation = display.rotation
|
||||||
|
}
|
||||||
|
|
||||||
|
private val mContext: WeakReference<Context>
|
||||||
|
private val mSensorManager: SensorManager
|
||||||
|
private val mAccelerometer: Sensor?
|
||||||
|
private val mNaturalOrientation: Int
|
||||||
|
|
||||||
|
fun enable() {
|
||||||
|
this.mSensorManager.registerListener(this, this.mAccelerometer, SensorManager.SENSOR_DELAY_GAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setInterval(interval: Float) {
|
||||||
|
this.mSensorManager.registerListener(this, this.mAccelerometer, (interval * 100000).roundToInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disable() {
|
||||||
|
this.mSensorManager.unregisterListener(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSensorChanged(pSensorEvent: SensorEvent) {
|
||||||
|
if (pSensorEvent.sensor.type != Sensor.TYPE_ACCELEROMETER) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var (x, y, z) = pSensorEvent.values
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Because the axes are not swapped when the device's screen orientation
|
||||||
|
* changes. So we should swap it here. In tablets such as Motorola Xoom,
|
||||||
|
* the default orientation is landscape, so should consider this.
|
||||||
|
*/
|
||||||
|
val orientation = this.mContext.get()!!.resources.configuration.orientation
|
||||||
|
|
||||||
|
if ((orientation == Configuration.ORIENTATION_LANDSCAPE) && (this.mNaturalOrientation != Surface.ROTATION_0)) {
|
||||||
|
val tmp = x
|
||||||
|
x = -y
|
||||||
|
y = tmp
|
||||||
|
} else if ((orientation == Configuration.ORIENTATION_PORTRAIT) && (this.mNaturalOrientation != Surface.ROTATION_0)) {
|
||||||
|
val tmp = x
|
||||||
|
x = y
|
||||||
|
y = -tmp
|
||||||
|
}
|
||||||
|
|
||||||
|
Cocos2dxGLSurfaceView.queueAccelerometer(x, y, z, pSensorEvent.timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAccuracyChanged(pSensor: Sensor, pAccuracy: Int) {}
|
||||||
|
}
|
||||||
@@ -1,31 +1,45 @@
|
|||||||
package org.cocos2dx.lib
|
package org.cocos2dx.lib
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.*
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
import android.graphics.Paint.FontMetricsInt
|
import android.graphics.Paint.FontMetricsInt
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.graphics.Typeface
|
||||||
import android.text.TextPaint
|
import android.text.TextPaint
|
||||||
import android.text.TextUtils
|
import android.text.TextUtils
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
|
import androidx.core.graphics.createBitmap
|
||||||
import java.lang.ref.WeakReference
|
import java.lang.ref.WeakReference
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
import java.util.*
|
import java.util.LinkedList
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.ceil
|
import kotlin.math.ceil
|
||||||
|
|
||||||
|
private const val HORIZONTALALIGN_LEFT = 1
|
||||||
|
private const val HORIZONTALALIGN_RIGHT = 2
|
||||||
|
private const val HORIZONTALALIGN_CENTER = 3
|
||||||
|
private const val VERTICALALIGN_TOP = 1
|
||||||
|
private const val VERTICALALIGN_BOTTOM = 2
|
||||||
|
private const val VERTICALALIGN_CENTER = 3
|
||||||
|
|
||||||
@Suppress("KotlinJniMissingFunction")
|
@Suppress("KotlinJniMissingFunction")
|
||||||
object Cocos2dxBitmap {
|
object Cocos2dxBitmap {
|
||||||
private lateinit var context: WeakReference<Context>
|
private lateinit var context: WeakReference<Context>
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
private external fun nativeInitBitmapDC(pWidth: Int, pHeight: Int, pPixels: ByteArray)
|
external fun nativeInitBitmapDC(pWidth: Int, pHeight: Int, pPixels: ByteArray)
|
||||||
|
|
||||||
fun setContext(context: Context) {
|
fun setContext(context: Context) {
|
||||||
this.context = WeakReference(context)
|
this.context = WeakReference(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
@JvmStatic
|
||||||
|
@Suppress("unused")
|
||||||
fun createTextBitmap(
|
fun createTextBitmap(
|
||||||
string: String,
|
string: String,
|
||||||
fontName: String,
|
fontName: String,
|
||||||
@@ -55,7 +69,6 @@ object Cocos2dxBitmap {
|
|||||||
1.0f
|
1.0f
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
@@ -79,19 +92,18 @@ object Cocos2dxBitmap {
|
|||||||
strokeB: Float,
|
strokeB: Float,
|
||||||
strokeSize: Float
|
strokeSize: Float
|
||||||
) {
|
) {
|
||||||
val bitmapTotalHeight: Int
|
val horizontalAlignment = alignment and 0x0F
|
||||||
val horizontalAlignment = alignment and 15
|
val verticalAlignment = (alignment shr 4) and 0x0F
|
||||||
val verticalAlignment = alignment shr 4 and 15
|
|
||||||
val pString2 = refactorString(string)
|
val pString2 = refactorString(string)
|
||||||
val paint = newPaint(fontName, fontSize, horizontalAlignment)
|
val paint = newPaint(fontName, fontSize, horizontalAlignment)
|
||||||
paint.setARGB(
|
paint.setARGB(
|
||||||
MotionEvent.ACTION_MASK,
|
255,
|
||||||
(255.0 * fontTintR.toDouble()).toInt(),
|
(255.0 * fontTintR).toInt(),
|
||||||
(255.0 * fontTintG.toDouble()).toInt(),
|
(255.0 * fontTintG).toInt(),
|
||||||
(255.0 * fontTintB.toDouble()).toInt()
|
(255.0 * fontTintB).toInt()
|
||||||
)
|
)
|
||||||
val textProperty = computeTextProperty(pString2, width, height, paint)
|
val textProperty = computeTextProperty(pString2, width, height, paint)
|
||||||
bitmapTotalHeight = if (height == 0) {
|
val bitmapTotalHeight = if (height == 0) {
|
||||||
textProperty.totalHeight
|
textProperty.totalHeight
|
||||||
} else {
|
} else {
|
||||||
height
|
height
|
||||||
@@ -101,7 +113,8 @@ object Cocos2dxBitmap {
|
|||||||
var renderTextDeltaX = 0.0f
|
var renderTextDeltaX = 0.0f
|
||||||
var renderTextDeltaY = 0.0f
|
var renderTextDeltaY = 0.0f
|
||||||
if (shadow) {
|
if (shadow) {
|
||||||
paint.setShadowLayer(shadowBlur, shadowDX, shadowDY, -8553091)
|
val shadowColor = 0xff7d7d7du.toInt()
|
||||||
|
paint.setShadowLayer(shadowBlur, shadowDX, shadowDY, shadowColor)
|
||||||
bitmapPaddingX = abs(shadowDX)
|
bitmapPaddingX = abs(shadowDX)
|
||||||
bitmapPaddingY = abs(shadowDY)
|
bitmapPaddingY = abs(shadowDY)
|
||||||
if (shadowDX.toDouble() < 0.0) {
|
if (shadowDX.toDouble() < 0.0) {
|
||||||
@@ -111,56 +124,47 @@ object Cocos2dxBitmap {
|
|||||||
renderTextDeltaY = bitmapPaddingY
|
renderTextDeltaY = bitmapPaddingY
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val bitmap = Bitmap.createBitmap(
|
|
||||||
|
val bitmap = try {
|
||||||
|
createBitmap(
|
||||||
textProperty.maxWidth + bitmapPaddingX.toInt(),
|
textProperty.maxWidth + bitmapPaddingX.toInt(),
|
||||||
bitmapPaddingY.toInt() + bitmapTotalHeight,
|
bitmapTotalHeight + bitmapPaddingY.toInt()
|
||||||
Bitmap.Config.ARGB_8888
|
|
||||||
)
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// bandaid fix, hopefully someone checks logs to see this
|
||||||
|
Log.e("Cocos2dxBitmap", "Error creating bitmap: $e")
|
||||||
|
nativeInitBitmapDC(0, 0, ByteArray(0))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val canvas = Canvas(bitmap)
|
val canvas = Canvas(bitmap)
|
||||||
val fontMetricsInt = paint.fontMetricsInt
|
val fontMetricsInt = paint.fontMetricsInt
|
||||||
var y = computeY(fontMetricsInt, height, textProperty.totalHeight, verticalAlignment)
|
var y = computeY(fontMetricsInt, height, textProperty.totalHeight, verticalAlignment)
|
||||||
val lines = textProperty.lines
|
val lines = textProperty.lines
|
||||||
val length = lines.size
|
|
||||||
for (i in 0 until length) {
|
for (line in lines) {
|
||||||
val line = lines[i]
|
val x = computeX(line, textProperty.maxWidth, horizontalAlignment)
|
||||||
canvas.drawText(
|
canvas.drawText(line, x + renderTextDeltaX, y + renderTextDeltaY, paint)
|
||||||
line!!,
|
|
||||||
computeX(
|
|
||||||
line,
|
|
||||||
textProperty.maxWidth,
|
|
||||||
horizontalAlignment
|
|
||||||
).toFloat() + renderTextDeltaX,
|
|
||||||
y.toFloat() + renderTextDeltaY,
|
|
||||||
paint
|
|
||||||
)
|
|
||||||
y += textProperty.heightPerLine
|
y += textProperty.heightPerLine
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stroke) {
|
if (stroke) {
|
||||||
val paintStroke = newPaint(fontName, fontSize, horizontalAlignment)
|
val paintStroke = newPaint(fontName, fontSize, horizontalAlignment)
|
||||||
paintStroke.style = Paint.Style.STROKE
|
paintStroke.style = Paint.Style.STROKE
|
||||||
paintStroke.strokeWidth = 0.5f * strokeSize
|
paintStroke.strokeWidth = strokeSize * 0.5f
|
||||||
paintStroke.setARGB(
|
paintStroke.setARGB(
|
||||||
MotionEvent.ACTION_MASK,
|
255,
|
||||||
strokeR.toInt() * MotionEvent.ACTION_MASK,
|
strokeR.toInt() * MotionEvent.ACTION_MASK,
|
||||||
strokeG.toInt() * MotionEvent.ACTION_MASK,
|
strokeG.toInt() * MotionEvent.ACTION_MASK,
|
||||||
strokeB.toInt() * MotionEvent.ACTION_MASK
|
strokeB.toInt() * MotionEvent.ACTION_MASK
|
||||||
)
|
)
|
||||||
var y2 = computeY(fontMetricsInt, height, textProperty.totalHeight, verticalAlignment)
|
var y = computeY(fontMetricsInt, height, textProperty.totalHeight, verticalAlignment)
|
||||||
val lines2 = textProperty.lines
|
val lines = textProperty.lines
|
||||||
val length2 = lines2.size
|
|
||||||
for (i2 in 0 until length2) {
|
for (line in lines) {
|
||||||
val line2 = lines2[i2]
|
val x = computeX(line, textProperty.maxWidth, horizontalAlignment)
|
||||||
canvas.drawText(
|
canvas.drawText(line, x + renderTextDeltaX, y + renderTextDeltaY, paintStroke)
|
||||||
line2!!,
|
y += textProperty.heightPerLine
|
||||||
computeX(
|
|
||||||
line2,
|
|
||||||
textProperty.maxWidth,
|
|
||||||
horizontalAlignment
|
|
||||||
).toFloat() + renderTextDeltaX,
|
|
||||||
y2.toFloat() + renderTextDeltaY,
|
|
||||||
paintStroke
|
|
||||||
)
|
|
||||||
y2 += textProperty.heightPerLine
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
initNativeObject(bitmap)
|
initNativeObject(bitmap)
|
||||||
@@ -168,24 +172,24 @@ object Cocos2dxBitmap {
|
|||||||
|
|
||||||
private fun newPaint(fontName: String, fontSize: Int, horizontalAlignment: Int): Paint {
|
private fun newPaint(fontName: String, fontSize: Int, horizontalAlignment: Int): Paint {
|
||||||
val paint = Paint()
|
val paint = Paint()
|
||||||
paint.color = -1
|
paint.color = Color.WHITE
|
||||||
paint.textSize = fontSize.toFloat()
|
paint.textSize = fontSize.toFloat()
|
||||||
paint.isAntiAlias = true
|
paint.isAntiAlias = true
|
||||||
if (fontName.endsWith(".ttf")) {
|
if (fontName.endsWith(".ttf")) {
|
||||||
try {
|
try {
|
||||||
context.get()?.apply {
|
context.get()!!.apply {
|
||||||
paint.typeface = Cocos2dxTypefaces[this, fontName]
|
paint.typeface = Cocos2dxTypefaces[this, fontName]
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("Cocos2dxBitmap", "error to create ttf type face: $fontName")
|
Log.e("Cocos2dxBitmap", "error to create ttf type face: $fontName - $e")
|
||||||
paint.typeface = Typeface.create(fontName, Typeface.NORMAL)
|
paint.typeface = Typeface.create(fontName, Typeface.NORMAL)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
paint.typeface = Typeface.create(fontName, Typeface.NORMAL)
|
paint.typeface = Typeface.create(fontName, Typeface.NORMAL)
|
||||||
}
|
}
|
||||||
when (horizontalAlignment) {
|
when (horizontalAlignment) {
|
||||||
2 -> paint.textAlign = Paint.Align.RIGHT
|
HORIZONTALALIGN_RIGHT -> paint.textAlign = Paint.Align.RIGHT
|
||||||
3 -> paint.textAlign = Paint.Align.CENTER
|
HORIZONTALALIGN_CENTER -> paint.textAlign = Paint.Align.CENTER
|
||||||
else -> paint.textAlign = Paint.Align.LEFT
|
else -> paint.textAlign = Paint.Align.LEFT
|
||||||
}
|
}
|
||||||
return paint
|
return paint
|
||||||
@@ -198,14 +202,14 @@ object Cocos2dxBitmap {
|
|||||||
paint: Paint
|
paint: Paint
|
||||||
): TextProperty {
|
): TextProperty {
|
||||||
val fm = paint.fontMetricsInt
|
val fm = paint.fontMetricsInt
|
||||||
val h = ceil((fm.bottom - fm.top).toDouble()).toInt()
|
val h = fm.bottom - fm.top
|
||||||
var maxContentWidth = 0
|
var maxContentWidth = 0
|
||||||
val lines = splitString(string, width, height, paint)
|
val lines = splitString(string, width, height, paint)
|
||||||
if (width != 0) {
|
if (width != 0) {
|
||||||
maxContentWidth = width
|
maxContentWidth = width
|
||||||
} else {
|
} else {
|
||||||
for (line in lines) {
|
for (line in lines) {
|
||||||
val temp = ceil(paint.measureText(line, 0, line!!.length).toDouble()).toInt()
|
val temp = ceil(paint.measureText(line, 0, line.length)).toInt()
|
||||||
if (temp > maxContentWidth) {
|
if (temp > maxContentWidth) {
|
||||||
maxContentWidth = temp
|
maxContentWidth = temp
|
||||||
}
|
}
|
||||||
@@ -214,10 +218,10 @@ object Cocos2dxBitmap {
|
|||||||
return TextProperty(maxContentWidth, h, lines)
|
return TextProperty(maxContentWidth, h, lines)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun computeX(@Suppress("UNUSED_PARAMETER") text: String?, maxWidth: Int, horizontalAlignment: Int): Int {
|
private fun computeX(@Suppress("UNUSED_PARAMETER") text: String, maxWidth: Int, horizontalAlignment: Int): Int {
|
||||||
return when (horizontalAlignment) {
|
return when (horizontalAlignment) {
|
||||||
2 -> maxWidth
|
HORIZONTALALIGN_RIGHT -> maxWidth
|
||||||
3 -> maxWidth / 2
|
HORIZONTALALIGN_CENTER -> maxWidth / 2
|
||||||
else -> 0
|
else -> 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,14 +233,16 @@ object Cocos2dxBitmap {
|
|||||||
verticalAlignment: Int
|
verticalAlignment: Int
|
||||||
): Int {
|
): Int {
|
||||||
val y = -fontMetricsInt.top
|
val y = -fontMetricsInt.top
|
||||||
return if (constrainHeight <= totalHeight) {
|
return if (constrainHeight > totalHeight) {
|
||||||
y
|
when (verticalAlignment) {
|
||||||
} else when (verticalAlignment) {
|
VERTICALALIGN_TOP -> -fontMetricsInt.top
|
||||||
1 -> -fontMetricsInt.top
|
VERTICALALIGN_CENTER -> -fontMetricsInt.top + (constrainHeight - totalHeight) / 2
|
||||||
2 -> -fontMetricsInt.top + (constrainHeight - totalHeight)
|
VERTICALALIGN_BOTTOM -> -fontMetricsInt.top + (constrainHeight - totalHeight)
|
||||||
3 -> -fontMetricsInt.top + (constrainHeight - totalHeight) / 2
|
|
||||||
else -> y
|
else -> y
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
y
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun splitString(
|
private fun splitString(
|
||||||
@@ -244,56 +250,56 @@ object Cocos2dxBitmap {
|
|||||||
maxWidth: Int,
|
maxWidth: Int,
|
||||||
maxHeight: Int,
|
maxHeight: Int,
|
||||||
paint: Paint
|
paint: Paint
|
||||||
): Array<String?> {
|
): List<String> {
|
||||||
val lines: Array<String?> = string.split("\\n").toTypedArray()
|
val lines = string.split("\n")
|
||||||
val fm = paint.fontMetricsInt
|
val fm = paint.fontMetricsInt
|
||||||
val maxLines = maxHeight / ceil((fm.bottom - fm.top).toDouble()).toInt()
|
val heightPerLine = fm.bottom - fm.top
|
||||||
|
val maxLines = maxHeight / heightPerLine
|
||||||
return if (maxWidth != 0) {
|
return if (maxWidth != 0) {
|
||||||
val strList = LinkedList<String?>()
|
val strList = LinkedList<String>()
|
||||||
for (line in lines) {
|
for (line in lines) {
|
||||||
if (ceil(paint.measureText(line).toDouble()).toInt() > maxWidth) {
|
val lineWidth = ceil(paint.measureText(line)).toInt()
|
||||||
|
|
||||||
|
if (lineWidth > maxWidth) {
|
||||||
strList.addAll(divideStringWithMaxWidth(line, maxWidth, paint))
|
strList.addAll(divideStringWithMaxWidth(line, maxWidth, paint))
|
||||||
} else {
|
} else {
|
||||||
strList.add(line)
|
strList.add(line)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (maxLines > 0 && strList.size >= maxLines) {
|
if (maxLines > 0 && strList.size >= maxLines) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (maxLines > 0 && strList.size > maxLines) {
|
if (maxLines > 0 && strList.size > maxLines) {
|
||||||
while (strList.size > maxLines) {
|
while (strList.size > maxLines) {
|
||||||
strList.removeLast()
|
strList.removeLast()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val ret = arrayOfNulls<String>(strList.size)
|
|
||||||
strList.toArray(ret)
|
strList
|
||||||
ret
|
} else if (maxHeight != 0 && lines.size > maxLines) {
|
||||||
} else if (maxHeight == 0 || lines.size <= maxLines) {
|
lines.take(maxLines)
|
||||||
lines
|
|
||||||
} else {
|
} else {
|
||||||
val strList2 = LinkedList<String?>()
|
lines
|
||||||
for (i in 0 until maxLines) {
|
|
||||||
strList2.add(lines[i])
|
|
||||||
}
|
|
||||||
val ret2 = arrayOfNulls<String>(strList2.size)
|
|
||||||
strList2.toArray(ret2)
|
|
||||||
ret2
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun divideStringWithMaxWidth(
|
private fun divideStringWithMaxWidth(
|
||||||
string: String?,
|
string: String,
|
||||||
maxWidth: Int,
|
maxWidth: Int,
|
||||||
paint: Paint
|
paint: Paint
|
||||||
): LinkedList<String?> {
|
): LinkedList<String> {
|
||||||
val charLength = string!!.length
|
val charLength = string.length
|
||||||
var start = 0
|
var start = 0
|
||||||
val strList = LinkedList<String?>()
|
val strList = LinkedList<String>()
|
||||||
|
|
||||||
var i = 1
|
var i = 1
|
||||||
while (i <= charLength) {
|
while (i <= charLength) {
|
||||||
val tempWidth = ceil(paint.measureText(string, start, i).toDouble()).toInt()
|
val tempWidth = ceil(paint.measureText(string, start, i)).toInt()
|
||||||
if (tempWidth >= maxWidth) {
|
if (tempWidth >= maxWidth) {
|
||||||
val lastIndexOfSpace = string.substring(0, i).lastIndexOf(" ")
|
val lastIndexOfSpace = string.substring(0, i).lastIndexOf(" ")
|
||||||
|
|
||||||
if (lastIndexOfSpace != -1 && lastIndexOfSpace > start) {
|
if (lastIndexOfSpace != -1 && lastIndexOfSpace > start) {
|
||||||
strList.add(string.substring(start, lastIndexOfSpace))
|
strList.add(string.substring(start, lastIndexOfSpace))
|
||||||
i = lastIndexOfSpace + 1
|
i = lastIndexOfSpace + 1
|
||||||
@@ -303,16 +309,20 @@ object Cocos2dxBitmap {
|
|||||||
} else {
|
} else {
|
||||||
strList.add(string.substring(start, i))
|
strList.add(string.substring(start, i))
|
||||||
}
|
}
|
||||||
|
|
||||||
while (i < charLength && string[i] == ' ') {
|
while (i < charLength && string[i] == ' ') {
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
|
|
||||||
start = i
|
start = i
|
||||||
}
|
}
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
|
|
||||||
if (start < charLength) {
|
if (start < charLength) {
|
||||||
strList.add(string.substring(start))
|
strList.add(string.substring(start))
|
||||||
}
|
}
|
||||||
|
|
||||||
return strList
|
return strList
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,19 +330,22 @@ object Cocos2dxBitmap {
|
|||||||
if (pString.compareTo("") == 0) {
|
if (pString.compareTo("") == 0) {
|
||||||
return " "
|
return " "
|
||||||
}
|
}
|
||||||
|
|
||||||
val strBuilder = StringBuilder(pString)
|
val strBuilder = StringBuilder(pString)
|
||||||
var start = 0
|
var start = 0
|
||||||
var index = strBuilder.indexOf("\n")
|
var index = strBuilder.indexOf("\n")
|
||||||
while (index != -1) {
|
while (index != -1) {
|
||||||
start = if (index == 0 || strBuilder[index - 1].code == 10) {
|
start = if (index == 0 || strBuilder[index - 1] == '\n') {
|
||||||
strBuilder.insert(start, " ")
|
strBuilder.insert(start, " ")
|
||||||
index + 2
|
index + 2
|
||||||
} else {
|
} else {
|
||||||
index + 1
|
index + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
if (start > strBuilder.length || index == strBuilder.length) {
|
if (start > strBuilder.length || index == strBuilder.length) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
index = strBuilder.indexOf("\n", start)
|
index = strBuilder.indexOf("\n", start)
|
||||||
}
|
}
|
||||||
return strBuilder.toString()
|
return strBuilder.toString()
|
||||||
@@ -340,15 +353,10 @@ object Cocos2dxBitmap {
|
|||||||
|
|
||||||
private fun initNativeObject(pBitmap: Bitmap) {
|
private fun initNativeObject(pBitmap: Bitmap) {
|
||||||
val pixels = getPixels(pBitmap)
|
val pixels = getPixels(pBitmap)
|
||||||
if (pixels != null) {
|
|
||||||
nativeInitBitmapDC(pBitmap.width, pBitmap.height, pixels)
|
nativeInitBitmapDC(pBitmap.width, pBitmap.height, pixels)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun getPixels(pBitmap: Bitmap?): ByteArray? {
|
private fun getPixels(pBitmap: Bitmap): ByteArray {
|
||||||
if (pBitmap == null) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
val pixels = ByteArray(pBitmap.width * pBitmap.height * 4)
|
val pixels = ByteArray(pBitmap.width * pBitmap.height * 4)
|
||||||
val buf = ByteBuffer.wrap(pixels)
|
val buf = ByteBuffer.wrap(pixels)
|
||||||
buf.order(ByteOrder.nativeOrder())
|
buf.order(ByteOrder.nativeOrder())
|
||||||
@@ -361,13 +369,18 @@ object Cocos2dxBitmap {
|
|||||||
private fun getFontSizeAccordingHeight(height: Int): Int {
|
private fun getFontSizeAccordingHeight(height: Int): Int {
|
||||||
val paint = Paint()
|
val paint = Paint()
|
||||||
val bounds = Rect()
|
val bounds = Rect()
|
||||||
|
|
||||||
paint.typeface = Typeface.DEFAULT
|
paint.typeface = Typeface.DEFAULT
|
||||||
var incrTextSize = 1
|
var incrTextSize = 1
|
||||||
var foundDesiredSize = false
|
var foundDesiredSize = false
|
||||||
|
|
||||||
while (!foundDesiredSize) {
|
while (!foundDesiredSize) {
|
||||||
paint.textSize = incrTextSize.toFloat()
|
paint.textSize = incrTextSize.toFloat()
|
||||||
paint.getTextBounds("SghMNy", 0, "SghMNy".length, bounds)
|
val text = "SghMNy"
|
||||||
|
paint.getTextBounds(text, 0, text.length, bounds)
|
||||||
|
|
||||||
incrTextSize++
|
incrTextSize++
|
||||||
|
|
||||||
if (height - bounds.height() <= 2) {
|
if (height - bounds.height() <= 2) {
|
||||||
foundDesiredSize = true
|
foundDesiredSize = true
|
||||||
}
|
}
|
||||||
@@ -382,23 +395,19 @@ object Cocos2dxBitmap {
|
|||||||
if (TextUtils.isEmpty(string)) {
|
if (TextUtils.isEmpty(string)) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
val paint = TextPaint()
|
val paint = TextPaint()
|
||||||
paint.typeface = Typeface.DEFAULT
|
paint.typeface = Typeface.DEFAULT
|
||||||
paint.textSize = fontSize
|
paint.textSize = fontSize
|
||||||
|
|
||||||
return TextUtils.ellipsize(string, paint, width, TextUtils.TruncateAt.END).toString()
|
return TextUtils.ellipsize(string, paint, width, TextUtils.TruncateAt.END).toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
private class TextProperty constructor(
|
private class TextProperty(
|
||||||
val maxWidth: Int,
|
val maxWidth: Int,
|
||||||
val heightPerLine: Int, pLines: Array<String?>
|
val heightPerLine: Int,
|
||||||
|
val lines: Collection<String>
|
||||||
) {
|
) {
|
||||||
val lines: Array<String?>
|
val totalHeight: Int = lines.size * heightPerLine
|
||||||
|
|
||||||
val totalHeight: Int
|
|
||||||
|
|
||||||
init {
|
|
||||||
totalHeight = pLines.size * heightPerLine
|
|
||||||
lines = pLines
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package org.cocos2dx.lib
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.view.KeyEvent
|
|
||||||
import androidx.appcompat.widget.AppCompatEditText
|
|
||||||
|
|
||||||
class Cocos2dxEditText(context: Context) : AppCompatEditText(context) {
|
|
||||||
var cocos2dxGLSurfaceView: Cocos2dxGLSurfaceView? = null
|
|
||||||
|
|
||||||
override fun onKeyDown(keyCode: Int, keyEvent: KeyEvent): Boolean {
|
|
||||||
super.onKeyDown(keyCode, keyEvent)
|
|
||||||
if (keyCode != KeyEvent.KEYCODE_BACK) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
cocos2dxGLSurfaceView?.requestFocus()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +1,60 @@
|
|||||||
package org.cocos2dx.lib
|
package org.cocos2dx.lib
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.hardware.input.InputManager
|
||||||
import android.opengl.GLSurfaceView
|
import android.opengl.GLSurfaceView
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.os.Message
|
import android.os.Message
|
||||||
import android.util.Log
|
import android.text.InputType
|
||||||
|
import android.view.InputDevice
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
|
import android.view.WindowInsets
|
||||||
|
import android.view.inputmethod.EditorInfo
|
||||||
import android.view.inputmethod.InputMethodManager
|
import android.view.inputmethod.InputMethodManager
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.appcompat.widget.AppCompatEditText
|
||||||
import com.customRobTop.BaseRobTopActivity
|
import com.customRobTop.BaseRobTopActivity
|
||||||
import com.geode.launcher.utils.GeodeUtils
|
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 MS_TO_NS = 1_000_000
|
||||||
|
private const val SEC_TO_NS = 1_000_000_000.0
|
||||||
|
|
||||||
|
fun inputTypeForFlags(i: Int): Int {
|
||||||
|
if ((i and BaseRobTopActivity.KEYBOARD_INPUT_NUMERIC) != 0) {
|
||||||
|
return InputType.TYPE_NUMBER_FLAG_DECIMAL or InputType.TYPE_NUMBER_FLAG_SIGNED or InputType.TYPE_CLASS_NUMBER
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((i and BaseRobTopActivity.KEYBOARD_INPUT_PASSWORD) != 0) {
|
||||||
|
return InputType.TYPE_TEXT_VARIATION_PASSWORD or InputType.TYPE_CLASS_TEXT
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((i and BaseRobTopActivity.KEYBOARD_INPUT_MULTILINE) != 0) {
|
||||||
|
return InputType.TYPE_TEXT_FLAG_MULTI_LINE or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or InputType.TYPE_CLASS_TEXT
|
||||||
|
}
|
||||||
|
|
||||||
|
return InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS or InputType.TYPE_CLASS_TEXT
|
||||||
|
}
|
||||||
|
|
||||||
|
fun usesInlineKeyboard(): Boolean {
|
||||||
|
return Build.VERSION.SDK_INT >= 30
|
||||||
|
}
|
||||||
|
|
||||||
|
fun imeOptionsForFlags(i: Int): Int {
|
||||||
|
val i2 = if ((i and BaseRobTopActivity.KEYBOARD_INPUT_MULTILINE) == 0 || !usesInlineKeyboard())
|
||||||
|
EditorInfo.IME_ACTION_DONE else EditorInfo.IME_ACTION_NONE
|
||||||
|
|
||||||
|
return if (usesInlineKeyboard())
|
||||||
|
EditorInfo.IME_FLAG_NO_FULLSCREEN or EditorInfo.IME_FLAG_NO_EXTRACT_UI or i2
|
||||||
|
else i2
|
||||||
|
}
|
||||||
|
|
||||||
class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
||||||
companion object {
|
companion object {
|
||||||
@@ -25,6 +62,19 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
|||||||
private lateinit var handler: Handler
|
private lateinit var handler: Handler
|
||||||
private lateinit var cocos2dxTextInputWrapper: Cocos2dxTextInputWrapper
|
private lateinit var cocos2dxTextInputWrapper: Cocos2dxTextInputWrapper
|
||||||
|
|
||||||
|
private var keyboardVisible = false
|
||||||
|
private var keyboardHeight = 0
|
||||||
|
|
||||||
|
private var keyboardHideCheck = Runnable {
|
||||||
|
if (keyboardVisible) {
|
||||||
|
return@Runnable
|
||||||
|
}
|
||||||
|
|
||||||
|
cocos2dxGLSurfaceView.queueEvent {
|
||||||
|
cocos2dxGLSurfaceView.cocos2dxRenderer.handleTextClosed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun openIMEKeyboard() {
|
fun openIMEKeyboard() {
|
||||||
val msg = Message()
|
val msg = Message()
|
||||||
@@ -39,6 +89,35 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
|||||||
msg.what = HANDLER_CLOSE_IME_KEYBOARD
|
msg.what = HANDLER_CLOSE_IME_KEYBOARD
|
||||||
handler.sendMessage(msg)
|
handler.sendMessage(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun queueAccelerometer(x: Float, y: Float, z: Float, timestamp: Long) =
|
||||||
|
cocos2dxGLSurfaceView.queueEvent {
|
||||||
|
Cocos2dxAccelerometer.onSensorChanged(x, y, z, timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.R)
|
||||||
|
fun onKeyboardInsets(windowInsets: WindowInsets) {
|
||||||
|
if (!cocos2dxGLSurfaceView.sendNewCocosEvents) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val visible = windowInsets.isVisible(WindowInsets.Type.ime())
|
||||||
|
val offsetHeight = if (visible) windowInsets.getInsets(WindowInsets.Type.ime()).bottom else 0
|
||||||
|
|
||||||
|
if (visible == keyboardVisible && offsetHeight == keyboardHeight) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
keyboardVisible = visible
|
||||||
|
keyboardHeight = offsetHeight
|
||||||
|
|
||||||
|
handler.removeCallbacks(keyboardHideCheck)
|
||||||
|
if (!visible) {
|
||||||
|
handler.postDelayed(keyboardHideCheck, 150L)
|
||||||
|
} else {
|
||||||
|
cocos2dxGLSurfaceView.cocos2dxRenderer.handleKeyboardWillShow(keyboardHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private lateinit var cocos2dxRenderer: Cocos2dxRenderer
|
private lateinit var cocos2dxRenderer: Cocos2dxRenderer
|
||||||
@@ -49,22 +128,26 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var sendTimestampEvents = false
|
var sendTimestampEvents = false
|
||||||
|
var manualBackEvents = false
|
||||||
|
var controllerEventsEnabled = false
|
||||||
|
private set
|
||||||
|
var supportsResizeEvents = false
|
||||||
|
|
||||||
var cocos2dxEditText: Cocos2dxEditText? = null
|
// 2.209+ events for keyboard and such
|
||||||
|
var sendNewCocosEvents = false
|
||||||
|
|
||||||
|
var cocos2dxEditText: AppCompatEditText? = null
|
||||||
set(value) {
|
set(value) {
|
||||||
field = value
|
field = value
|
||||||
|
|
||||||
field?.setOnEditorActionListener(cocos2dxTextInputWrapper)
|
field?.setOnEditorActionListener(cocos2dxTextInputWrapper)
|
||||||
field?.cocos2dxGLSurfaceView = this
|
|
||||||
requestFocus()
|
requestFocus()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun initView() {
|
fun initView() {
|
||||||
setEGLContextClientVersion(2)
|
setEGLContextClientVersion(2)
|
||||||
isFocusableInTouchMode = true
|
isFocusableInTouchMode = true
|
||||||
// if (Build.VERSION.SDK_INT >= 11) {
|
|
||||||
preserveEGLContextOnPause = true
|
preserveEGLContextOnPause = true
|
||||||
// }
|
|
||||||
cocos2dxGLSurfaceView = this
|
cocos2dxGLSurfaceView = this
|
||||||
cocos2dxTextInputWrapper = Cocos2dxTextInputWrapper(this)
|
cocos2dxTextInputWrapper = Cocos2dxTextInputWrapper(this)
|
||||||
Cocos2dxGLSurfaceView.handler = object : Handler(Looper.getMainLooper()) {
|
Cocos2dxGLSurfaceView.handler = object : Handler(Looper.getMainLooper()) {
|
||||||
@@ -73,9 +156,13 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
|||||||
HANDLER_OPEN_IME_KEYBOARD -> {
|
HANDLER_OPEN_IME_KEYBOARD -> {
|
||||||
if (cocos2dxEditText?.requestFocus() == true) {
|
if (cocos2dxEditText?.requestFocus() == true) {
|
||||||
cocos2dxEditText?.apply {
|
cocos2dxEditText?.apply {
|
||||||
removeTextChangedListener(
|
removeTextChangedListener(cocos2dxTextInputWrapper)
|
||||||
cocos2dxTextInputWrapper
|
|
||||||
)
|
if (sendNewCocosEvents) {
|
||||||
|
inputType = inputTypeForFlags(BaseRobTopActivity.curKeyboardInputFlags)
|
||||||
|
imeOptions = imeOptionsForFlags(BaseRobTopActivity.curKeyboardInputFlags)
|
||||||
|
}
|
||||||
|
|
||||||
setText("")
|
setText("")
|
||||||
val text = msg.obj as String
|
val text = msg.obj as String
|
||||||
append(text)
|
append(text)
|
||||||
@@ -88,16 +175,13 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
|||||||
cocos2dxEditText,
|
cocos2dxEditText,
|
||||||
0
|
0
|
||||||
)
|
)
|
||||||
Log.d("GLSurfaceView", "showSoftInput")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
HANDLER_CLOSE_IME_KEYBOARD -> {
|
HANDLER_CLOSE_IME_KEYBOARD -> {
|
||||||
cocos2dxEditText?.apply {
|
cocos2dxEditText?.apply {
|
||||||
removeTextChangedListener(
|
removeTextChangedListener(cocos2dxTextInputWrapper)
|
||||||
cocos2dxTextInputWrapper
|
|
||||||
)
|
|
||||||
(context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(
|
(context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(
|
||||||
windowToken,
|
windowToken,
|
||||||
0
|
0
|
||||||
@@ -105,8 +189,10 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
requestFocus()
|
requestFocus()
|
||||||
Log.d("GLSurfaceView", "HideSoftInput")
|
|
||||||
|
queueEvent {
|
||||||
cocos2dxRenderer.handleTextClosed()
|
cocos2dxRenderer.handleTextClosed()
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
else -> return
|
else -> return
|
||||||
@@ -117,23 +203,39 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
|||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.R)
|
@RequiresApi(Build.VERSION_CODES.R)
|
||||||
fun updateRefreshRate() {
|
fun updateRefreshRate() {
|
||||||
val maxRefreshRate = display.supportedModes.maxBy { it.refreshRate }
|
val chosenDisplay = display.supportedModes?.maxByOrNull { it.refreshRate }
|
||||||
holder.surface.setFrameRate(
|
if (chosenDisplay == null) {
|
||||||
maxRefreshRate.refreshRate,
|
println("updateRefreshRate failed to find a display to maximize refresh rate...")
|
||||||
Surface.FRAME_RATE_COMPATIBILITY_DEFAULT
|
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)
|
||||||
@@ -141,6 +243,7 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
|||||||
val ys = FloatArray(pointerNumber)
|
val ys = FloatArray(pointerNumber)
|
||||||
val timestamp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
val timestamp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||||
motionEvent.eventTimeNanos else motionEvent.eventTime * MS_TO_NS
|
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)
|
||||||
@@ -153,10 +256,16 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
|||||||
val xDown = xs[0]
|
val xDown = xs[0]
|
||||||
val f = ys[0]
|
val f = ys[0]
|
||||||
queueEvent {
|
queueEvent {
|
||||||
if (sendTimestampEvents)
|
sendNextEventTimestamp(timestamp)
|
||||||
GeodeUtils.setNextInputTimestamp(timestamp)
|
|
||||||
|
|
||||||
cocos2dxRenderer.handleActionDown(idDown, xDown, f)
|
if (controllerEventsEnabled)
|
||||||
|
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
|
||||||
}
|
}
|
||||||
@@ -165,28 +274,43 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
|||||||
val f2 = xs[0]
|
val f2 = xs[0]
|
||||||
val f3 = ys[0]
|
val f3 = ys[0]
|
||||||
queueEvent {
|
queueEvent {
|
||||||
if (sendTimestampEvents)
|
sendNextEventTimestamp(timestamp)
|
||||||
GeodeUtils.setNextInputTimestamp(timestamp)
|
|
||||||
|
|
||||||
cocos2dxRenderer.handleActionUp(idUp, f2, f3)
|
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 {
|
||||||
if (sendTimestampEvents)
|
sendNextEventTimestamp(timestamp)
|
||||||
GeodeUtils.setNextInputTimestamp(timestamp)
|
|
||||||
|
|
||||||
cocos2dxRenderer.handleActionMove(ids, xs, ys)
|
if (controllerEventsEnabled)
|
||||||
|
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 {
|
||||||
if (sendTimestampEvents)
|
sendNextEventTimestamp(timestamp)
|
||||||
GeodeUtils.setNextInputTimestamp(timestamp)
|
|
||||||
|
|
||||||
cocos2dxRenderer.handleActionCancel(ids, xs, ys)
|
if (controllerEventsEnabled)
|
||||||
|
GeodeUtils.internalTouchEvent(
|
||||||
|
timestamp, motionEvent.deviceId, motionEvent.source,
|
||||||
|
GeodeUtils.TOUCH_TYPE_CANCEL, ids, xs, ys
|
||||||
|
)
|
||||||
|
else
|
||||||
|
cocos2dxRenderer.handleActionCancel(ids, xs, ys, timestampSec)
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -196,10 +320,15 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
|||||||
val xPointerDown = motionEvent.getX(indexPointerDown)
|
val xPointerDown = motionEvent.getX(indexPointerDown)
|
||||||
val y = motionEvent.getY(indexPointerDown)
|
val y = motionEvent.getY(indexPointerDown)
|
||||||
queueEvent {
|
queueEvent {
|
||||||
if (sendTimestampEvents)
|
sendNextEventTimestamp(timestamp)
|
||||||
GeodeUtils.setNextInputTimestamp(timestamp)
|
|
||||||
|
|
||||||
cocos2dxRenderer.handleActionDown(idPointerDown, xPointerDown, y)
|
if (controllerEventsEnabled)
|
||||||
|
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
|
||||||
}
|
}
|
||||||
@@ -209,10 +338,15 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
|||||||
val xPointerUp = motionEvent.getX(indexPointUp)
|
val xPointerUp = motionEvent.getX(indexPointUp)
|
||||||
val y2 = motionEvent.getY(indexPointUp)
|
val y2 = motionEvent.getY(indexPointUp)
|
||||||
queueEvent {
|
queueEvent {
|
||||||
if (sendTimestampEvents)
|
sendNextEventTimestamp(timestamp)
|
||||||
GeodeUtils.setNextInputTimestamp(timestamp)
|
|
||||||
|
|
||||||
cocos2dxRenderer.handleActionUp(idPointerUp, xPointerUp, y2)
|
if (controllerEventsEnabled)
|
||||||
|
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
|
||||||
}
|
}
|
||||||
@@ -229,6 +363,103 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
|||||||
if (!isInEditMode) {
|
if (!isInEditMode) {
|
||||||
cocos2dxRenderer.setScreenWidthAndHeight(newSurfaceWidth, newSurfaceHeight)
|
cocos2dxRenderer.setScreenWidthAndHeight(newSurfaceWidth, newSurfaceHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (supportsResizeEvents) {
|
||||||
|
queueEvent {
|
||||||
|
GeodeUtils.resizeSurface(newSurfaceWidth, newSurfaceHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun queueKeyboardEvent(keyCode: Int, event: KeyEvent, down: Boolean): Boolean {
|
||||||
|
val keypadCode = keyCode == KeyEvent.KEYCODE_MENU || keyCode == KeyEvent.KEYCODE_BACK
|
||||||
|
if (event.isSystem && !keypadCode) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keypadCode && BaseRobTopActivity.blockBackButton) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
queueEvent {
|
||||||
|
sendNextEventTimestamp(event.eventTime * MS_TO_NS)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useKeyboardEvents) {
|
||||||
|
if (down) {
|
||||||
|
queueEvent {
|
||||||
|
GeodeUtils.nativeKeyDown(keyCode, event.modifiers, event.repeatCount != 0)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
queueEvent {
|
||||||
|
GeodeUtils.nativeKeyUp(keyCode, event.modifiers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (controllerEventsEnabled) {
|
||||||
|
queueEvent {
|
||||||
|
GeodeUtils.internalKeyEvent(
|
||||||
|
event.eventTime * MS_TO_NS,
|
||||||
|
event.deviceId,
|
||||||
|
event.source,
|
||||||
|
event.keyCode,
|
||||||
|
event.modifiers,
|
||||||
|
down,
|
||||||
|
event.repeatCount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// geode events override native callbacks
|
||||||
|
if (useKeyboardEvents || controllerEventsEnabled) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sendNewCocosEvents) {
|
||||||
|
if (KeyEvent.isGamepadButton(keyCode)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyCode == KeyEvent.KEYCODE_ESCAPE && BaseRobTopActivity.blockBackButton) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// messy logic
|
||||||
|
val isRepeat = event.repeatCount > 0
|
||||||
|
if (keypadCode) {
|
||||||
|
if (down) {
|
||||||
|
if (isRepeat || BaseRobTopActivity.blockBackButton) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
queueEvent {
|
||||||
|
cocos2dxRenderer.handleKeyDown(keyCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val eventTimestamp = event.eventTime / 1000.0
|
||||||
|
|
||||||
|
val shiftPressed = event.isShiftPressed
|
||||||
|
val ctrlPressed = event.isCtrlPressed
|
||||||
|
val altPressed = event.isAltPressed
|
||||||
|
|
||||||
|
queueEvent {
|
||||||
|
cocos2dxRenderer.handleKeyboardEvent(
|
||||||
|
keyCode, down, isRepeat, eventTimestamp,
|
||||||
|
shiftPressed, ctrlPressed, altPressed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun legacyKeyDown(keyCode: Int, keyEvent: KeyEvent): Boolean {
|
private fun legacyKeyDown(keyCode: Int, keyEvent: KeyEvent): Boolean {
|
||||||
@@ -244,76 +475,180 @@ 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 {
|
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
|
||||||
if (!useKeyboardEvents) {
|
if (!queueKeyboardEvent(keyCode, event, true)) {
|
||||||
return legacyKeyDown(keyCode, event)
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
queueEvent {
|
|
||||||
if (sendTimestampEvents)
|
|
||||||
GeodeUtils.setNextInputTimestamp(event.eventTime * MS_TO_NS)
|
|
||||||
|
|
||||||
GeodeUtils.nativeKeyDown(keyCode, event.modifiers, event.repeatCount != 0)
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean {
|
override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean {
|
||||||
if (!useKeyboardEvents) {
|
if (!queueKeyboardEvent(keyCode, event, false)) {
|
||||||
return super.onKeyUp(keyCode, event)
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
queueEvent {
|
// i'm lazy.
|
||||||
if (sendTimestampEvents)
|
// https://developer.android.com/develop/ui/views/touch-and-input/game-controllers/controller-input
|
||||||
GeodeUtils.setNextInputTimestamp(event.eventTime * MS_TO_NS)
|
private fun getCenteredAxis(
|
||||||
|
event: MotionEvent,
|
||||||
GeodeUtils.nativeKeyUp(keyCode, event.modifiers)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
true
|
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 {
|
override fun onGenericMotionEvent(event: MotionEvent?): Boolean {
|
||||||
if (!useKeyboardEvents) {
|
val hasGeodeEvents = useKeyboardEvents || controllerEventsEnabled
|
||||||
|
if (!hasGeodeEvents && !sendNewCocosEvents) {
|
||||||
return super.onGenericMotionEvent(event)
|
return super.onGenericMotionEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event?.action == MotionEvent.ACTION_SCROLL) {
|
if (event?.action == MotionEvent.ACTION_SCROLL) {
|
||||||
|
val mouseX = event.x
|
||||||
|
val mouseY = event.y
|
||||||
|
|
||||||
val scrollX = event.getAxisValue(MotionEvent.AXIS_HSCROLL)
|
val scrollX = event.getAxisValue(MotionEvent.AXIS_HSCROLL)
|
||||||
val scrollY = event.getAxisValue(MotionEvent.AXIS_VSCROLL)
|
val scrollY = event.getAxisValue(MotionEvent.AXIS_VSCROLL)
|
||||||
val timestamp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
val timestamp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||||
event.eventTimeNanos else event.eventTime * MS_TO_NS
|
event.eventTimeNanos else event.eventTime * MS_TO_NS
|
||||||
|
|
||||||
queueEvent {
|
queueEvent {
|
||||||
if (sendTimestampEvents)
|
sendNextEventTimestamp(timestamp)
|
||||||
GeodeUtils.setNextInputTimestamp(timestamp)
|
|
||||||
|
|
||||||
|
if (useKeyboardEvents) {
|
||||||
GeodeUtils.nativeActionScroll(scrollX, scrollY)
|
GeodeUtils.nativeActionScroll(scrollX, scrollY)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (controllerEventsEnabled) {
|
||||||
|
GeodeUtils.internalScrollEvent(timestamp, event.deviceId, event.source, scrollX, scrollY, mouseX, mouseY)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasGeodeEvents && sendNewCocosEvents) {
|
||||||
|
cocos2dxRenderer.handleScroll(mouseX, mouseY, scrollY, scrollX)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (controllerEventsEnabled && event?.action == MotionEvent.ACTION_MOVE && event.source and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK) {
|
||||||
|
val (motionLeftX, motionLeftY) = getJoystickMotion(event, MotionEvent.AXIS_X, MotionEvent.AXIS_Y)
|
||||||
|
val (motionRightX, motionRightY) = getJoystickMotion(event, MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ)
|
||||||
|
val (motionDPadX, motionDPadY) = getJoystickMotion(event, MotionEvent.AXIS_HAT_X, MotionEvent.AXIS_HAT_Y)
|
||||||
|
|
||||||
|
val motionLTrigger = getJoystickMotion(event, MotionEvent.AXIS_LTRIGGER)
|
||||||
|
val motionRTrigger = getJoystickMotion(event, MotionEvent.AXIS_RTRIGGER)
|
||||||
|
|
||||||
|
// i'm sure this is great for performance
|
||||||
|
val timestamp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||||
|
event.eventTimeNanos else event.eventTime * MS_TO_NS
|
||||||
|
|
||||||
|
queueEvent {
|
||||||
|
GeodeUtils.internalJoystickEvent(
|
||||||
|
timestamp, event.deviceId, event.source,
|
||||||
|
motionLeftX, motionLeftY,
|
||||||
|
motionRightX, motionRightY,
|
||||||
|
motionDPadX, motionDPadY,
|
||||||
|
motionLTrigger, motionRTrigger
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,4 +671,40 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
|||||||
fun deleteBackward() {
|
fun deleteBackward() {
|
||||||
queueEvent { cocos2dxRenderer.handleDeleteBackward() }
|
queueEvent { cocos2dxRenderer.handleDeleteBackward() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun enableControllerEvents() {
|
||||||
|
if (controllerEventsEnabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
controllerEventsEnabled = true
|
||||||
|
|
||||||
|
val inputManager = context.getSystemService(Context.INPUT_SERVICE) as InputManager
|
||||||
|
inputManager.registerInputDeviceListener(InputListener, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disableControllerEvents() {
|
||||||
|
if (!controllerEventsEnabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
controllerEventsEnabled = true
|
||||||
|
|
||||||
|
val inputManager = context.getSystemService(Context.INPUT_SERVICE) as InputManager
|
||||||
|
inputManager.unregisterInputDeviceListener(InputListener)
|
||||||
|
}
|
||||||
|
|
||||||
|
object InputListener : InputManager.InputDeviceListener {
|
||||||
|
override fun onInputDeviceAdded(deviceId: Int) {
|
||||||
|
GeodeUtils.inputDeviceAdded(deviceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onInputDeviceChanged(deviceId: Int) {
|
||||||
|
GeodeUtils.inputDeviceChanged(deviceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onInputDeviceRemoved(deviceId: Int) {
|
||||||
|
GeodeUtils.inputDeviceRemoved(deviceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ package org.cocos2dx.lib
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.ApplicationInfo
|
import android.content.pm.ApplicationInfo
|
||||||
|
import android.content.res.AssetManager
|
||||||
import android.os.Process
|
import android.os.Process
|
||||||
import com.customRobTop.BaseRobTopActivity
|
import com.customRobTop.BaseRobTopActivity
|
||||||
import com.geode.launcher.utils.LaunchUtils
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
@@ -11,9 +12,9 @@ import com.geode.launcher.utils.LaunchUtils
|
|||||||
object Cocos2dxHelper {
|
object Cocos2dxHelper {
|
||||||
// private val sCocos2dMusic: Cocos2dxMusic? = null
|
// private val sCocos2dMusic: Cocos2dxMusic? = null
|
||||||
// private val sCocos2dSound: Cocos2dxSound? = null
|
// private val sCocos2dSound: Cocos2dxSound? = null
|
||||||
// private val sAssetManager: AssetManager? = null
|
private var sAssetManager: AssetManager? = null
|
||||||
// private val sCocos2dxAccelerometer: Cocos2dxAccelerometer? = null
|
private var sCocos2dxAccelerometer: Cocos2dxAccelerometer? = null
|
||||||
// private val sAccelerometerEnabled = false
|
private var sAccelerometerEnabled = false
|
||||||
private var packageName: String? = null
|
private var packageName: String? = null
|
||||||
private var fileDirectory: String? = null
|
private var fileDirectory: String? = null
|
||||||
|
|
||||||
@@ -36,6 +37,40 @@ object Cocos2dxHelper {
|
|||||||
Process.killProcess(Process.myPid())
|
Process.killProcess(Process.myPid())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun getAssetManager(): AssetManager? {
|
||||||
|
return sAssetManager
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun enableAccelerometer() {
|
||||||
|
sAccelerometerEnabled = true
|
||||||
|
sCocos2dxAccelerometer?.enable()
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun setAccelerometerInterval(interval: Float) {
|
||||||
|
sCocos2dxAccelerometer?.setInterval(interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun disableAccelerometer() {
|
||||||
|
sAccelerometerEnabled = false
|
||||||
|
sCocos2dxAccelerometer?.disable()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onResume() {
|
||||||
|
if (sAccelerometerEnabled) {
|
||||||
|
sCocos2dxAccelerometer?.enable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onPause() {
|
||||||
|
if (sAccelerometerEnabled) {
|
||||||
|
sCocos2dxAccelerometer?.disable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun getDPI(): Int {
|
fun getDPI(): Int {
|
||||||
return BaseRobTopActivity.me.get()?.resources?.configuration?.densityDpi ?: -1
|
return BaseRobTopActivity.me.get()?.resources?.configuration?.densityDpi ?: -1
|
||||||
@@ -52,14 +87,14 @@ object Cocos2dxHelper {
|
|||||||
this.cocos2dxHelperListener = cocos2dxHelperListener
|
this.cocos2dxHelperListener = cocos2dxHelperListener
|
||||||
packageName = applicationInfo.packageName
|
packageName = applicationInfo.packageName
|
||||||
fileDirectory = LaunchUtils.getSaveDirectory(context).absolutePath
|
fileDirectory = LaunchUtils.getSaveDirectory(context).absolutePath
|
||||||
// Cocos2dxHelper.sCocos2dxAccelerometer = Cocos2dxAccelerometer(pContext)
|
sCocos2dxAccelerometer = Cocos2dxAccelerometer(context)
|
||||||
// Cocos2dxHelper.sCocos2dMusic = Cocos2dxMusic(pContext)
|
// Cocos2dxHelper.sCocos2dMusic = Cocos2dxMusic(pContext)
|
||||||
// var simultaneousStreams: Int = Cocos2dxSound.MAX_SIMULTANEOUS_STREAMS_DEFAULT
|
// var simultaneousStreams: Int = Cocos2dxSound.MAX_SIMULTANEOUS_STREAMS_DEFAULT
|
||||||
// if (Cocos2dxHelper.getDeviceModel().indexOf("GT-I9100") !== -1) {
|
// if (Cocos2dxHelper.getDeviceModel().indexOf("GT-I9100") !== -1) {
|
||||||
// simultaneousStreams = Cocos2dxSound.MAX_SIMULTANEOUS_STREAMS_I9100
|
// simultaneousStreams = Cocos2dxSound.MAX_SIMULTANEOUS_STREAMS_I9100
|
||||||
// }
|
// }
|
||||||
// Cocos2dxHelper.sCocos2dSound = Cocos2dxSound(pContext, simultaneousStreams)
|
// Cocos2dxHelper.sCocos2dSound = Cocos2dxSound(pContext, simultaneousStreams)
|
||||||
// Cocos2dxHelper.sAssetManager = pContext.getAssets()
|
sAssetManager = context.assets
|
||||||
Cocos2dxBitmap.setContext(context)
|
Cocos2dxBitmap.setContext(context)
|
||||||
// Cocos2dxETCLoader.setContext(pContext)
|
// Cocos2dxETCLoader.setContext(pContext)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ 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(private var handler: Cocos2dxGLSurfaceView) : GLSurfaceView.Renderer {
|
class Cocos2dxRenderer(private var handler: Cocos2dxGLSurfaceView) : GLSurfaceView.Renderer {
|
||||||
companion object {
|
companion object {
|
||||||
@@ -25,33 +28,54 @@ class Cocos2dxRenderer(private var handler: Cocos2dxGLSurfaceView) : GLSurfaceVi
|
|||||||
@JvmStatic
|
@JvmStatic
|
||||||
private external fun nativeKeyDown(keyCode: Int): Boolean
|
private external fun nativeKeyDown(keyCode: Int): Boolean
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
private external fun nativeKeyboardEvent(
|
||||||
|
keyCode: Int,
|
||||||
|
down: Boolean,
|
||||||
|
isRepeat: Boolean,
|
||||||
|
timestamp: Double,
|
||||||
|
shiftPressed: Boolean,
|
||||||
|
ctrlPressed: Boolean,
|
||||||
|
altPressed: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
private external fun nativeKeyboardWillShow(offset: Int)
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
private external fun nativeOnPause()
|
private external fun nativeOnPause()
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
private external fun nativeOnResume()
|
private external fun nativeOnResume()
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
private external fun nativeScroll(x: Float, y: Float, scrollY: Float, scrollX: Float)
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
private external fun nativeRender()
|
private external fun nativeRender()
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
private external fun nativeTextClosed()
|
private external fun nativeTextClosed()
|
||||||
|
|
||||||
|
// timestamp was added in 2.208, but the jni method has no required type signature.
|
||||||
|
// it doesn't hurt to unconditionally send one
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
private external fun nativeTouchesBegin(id: Int, x: Float, y: Float)
|
private external fun nativeTouchesBegin(id: Int, x: Float, y: Float, timestamp: Double)
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
private external fun nativeTouchesCancel(
|
private external fun nativeTouchesCancel(
|
||||||
ids: IntArray,
|
ids: IntArray,
|
||||||
xs: FloatArray,
|
xs: FloatArray,
|
||||||
ys: FloatArray
|
ys: FloatArray,
|
||||||
|
timestamp: Double,
|
||||||
)
|
)
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
private external fun nativeTouchesEnd(id: Int, x: Float, y: Float)
|
private external fun nativeTouchesEnd(id: Int, x: Float, y: Float, timestamp: Double)
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
private external fun nativeTouchesMove(ids: IntArray, xs: FloatArray, ys: FloatArray)
|
private external fun nativeTouchesMove(ids: IntArray, xs: FloatArray, ys: FloatArray, timestamp: Double)
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
private external fun nativeInit(width: Int, height: Int)
|
private external fun nativeInit(width: Int, height: Int)
|
||||||
@@ -62,6 +86,11 @@ class Cocos2dxRenderer(private var handler: Cocos2dxGLSurfaceView) : GLSurfaceVi
|
|||||||
private var screenHeight = 0
|
private var screenHeight = 0
|
||||||
var sendResizeEvents = false
|
var sendResizeEvents = false
|
||||||
var setFrameRate = 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
|
||||||
@@ -77,32 +106,62 @@ class Cocos2dxRenderer(private var handler: Cocos2dxGLSurfaceView) : GLSurfaceVi
|
|||||||
if (setFrameRate && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
if (setFrameRate && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
handler.updateRefreshRate()
|
handler.updateRefreshRate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
println("renderer surfaceChanged: ${width}x${height}@${handler.display.refreshRate}fps")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDrawFrame(gl: GL10?) {
|
override fun onDrawFrame(gl: GL10?) {
|
||||||
|
val animationInterval = mAnimationInterval
|
||||||
|
if (animationInterval == null) {
|
||||||
nativeRender()
|
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) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleActionDown(id: Int, x: Float, y: Float) {
|
lastTickInNanoSeconds = System.nanoTime()
|
||||||
nativeTouchesBegin(id, x, y)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleActionUp(id: Int, x: Float, y: Float) {
|
fun handleActionDown(id: Int, x: Float, y: Float, timestamp: Double) {
|
||||||
nativeTouchesEnd(id, x, y)
|
nativeTouchesBegin(id, x, y, timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleActionCancel(ids: IntArray, xs: FloatArray, ys: FloatArray) {
|
fun handleActionUp(id: Int, x: Float, y: Float, timestamp: Double) {
|
||||||
nativeTouchesCancel(ids, xs, ys)
|
nativeTouchesEnd(id, x, y, timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleActionMove(ids: IntArray, xs: FloatArray, ys: FloatArray) {
|
fun handleActionCancel(ids: IntArray, xs: FloatArray, ys: FloatArray, timestamp: Double) {
|
||||||
nativeTouchesMove(ids, xs, ys)
|
nativeTouchesCancel(ids, xs, ys, timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleActionMove(ids: IntArray, xs: FloatArray, ys: FloatArray, timestamp: Double) {
|
||||||
|
nativeTouchesMove(ids, xs, ys, timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleKeyDown(keyCode: Int) {
|
fun handleKeyDown(keyCode: Int) {
|
||||||
nativeKeyDown(keyCode)
|
nativeKeyDown(keyCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun handleKeyboardWillShow(offset: Int) {
|
||||||
|
nativeKeyboardWillShow(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleKeyboardEvent(keyCode: Int, down: Boolean, isRepeat: Boolean, timestamp: Double, shiftPressed: Boolean, ctrlPressed: Boolean, altPressed: Boolean) {
|
||||||
|
nativeKeyboardEvent(keyCode, down, isRepeat, timestamp, shiftPressed, ctrlPressed, altPressed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleScroll(x: Float, y: Float, scrollY: Float, scrollX: Float) {
|
||||||
|
nativeScroll(x, y, scrollY, scrollX)
|
||||||
|
}
|
||||||
|
|
||||||
fun handleOnPause() {
|
fun handleOnPause() {
|
||||||
nativeOnPause()
|
nativeOnPause()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import android.text.TextWatcher
|
|||||||
import android.widget.TextView.OnEditorActionListener
|
import android.widget.TextView.OnEditorActionListener
|
||||||
import android.text.Editable
|
import android.text.Editable
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
|
import android.view.inputmethod.EditorInfo
|
||||||
import android.view.inputmethod.InputMethodManager
|
import android.view.inputmethod.InputMethodManager
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
|
import com.customRobTop.JniToCpp
|
||||||
|
|
||||||
class Cocos2dxTextInputWrapper(private val cocos2dxGLSurfaceView: Cocos2dxGLSurfaceView) : TextWatcher, OnEditorActionListener {
|
class Cocos2dxTextInputWrapper(private val cocos2dxGLSurfaceView: Cocos2dxGLSurfaceView) : TextWatcher, OnEditorActionListener {
|
||||||
private var originText: String? = null
|
private var originText: String? = null
|
||||||
@@ -19,21 +21,33 @@ class Cocos2dxTextInputWrapper(private val cocos2dxGLSurfaceView: Cocos2dxGLSurf
|
|||||||
this.originText = originText
|
this.originText = originText
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun afterTextChanged(s: Editable) {
|
override fun afterTextChanged(editable: Editable) {
|
||||||
if (!isFullScreenEdit) {
|
if (isFullScreenEdit) {
|
||||||
var nModified = s.length - text!!.length
|
return
|
||||||
if (nModified > 0) {
|
}
|
||||||
cocos2dxGLSurfaceView.insertText(
|
|
||||||
s.subSequence(text!!.length, s.length).toString()
|
val cText = text ?: return
|
||||||
)
|
|
||||||
} else {
|
val str = editable.toString()
|
||||||
while (nModified < 0) {
|
val unchanged = str.commonPrefixWith(cText)
|
||||||
|
|
||||||
|
var unchangedUntil = unchanged.length
|
||||||
|
if (unchangedUntil > 0 && unchangedUntil < str.length && Character.isLowSurrogate(str[unchangedUntil])) {
|
||||||
|
unchangedUntil--
|
||||||
|
}
|
||||||
|
|
||||||
|
val codePointLength = cText.codePointCount(unchangedUntil, cText.length)
|
||||||
|
repeat(codePointLength) {
|
||||||
cocos2dxGLSurfaceView.deleteBackward()
|
cocos2dxGLSurfaceView.deleteBackward()
|
||||||
nModified++
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (unchangedUntil < str.length) {
|
||||||
|
cocos2dxGLSurfaceView.insertText(
|
||||||
|
str.substring(unchangedUntil)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
text = s.toString()
|
|
||||||
}
|
text = str
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun beforeTextChanged(
|
override fun beforeTextChanged(
|
||||||
@@ -47,24 +61,45 @@ class Cocos2dxTextInputWrapper(private val cocos2dxGLSurfaceView: Cocos2dxGLSurf
|
|||||||
|
|
||||||
override fun onTextChanged(pCharSequence: CharSequence, start: Int, before: Int, count: Int) {}
|
override fun onTextChanged(pCharSequence: CharSequence, start: Int, before: Int, count: Int) {}
|
||||||
override fun onEditorAction(textView: TextView, actionID: Int, keyEvent: KeyEvent?): Boolean {
|
override fun onEditorAction(textView: TextView, actionID: Int, keyEvent: KeyEvent?): Boolean {
|
||||||
if (cocos2dxGLSurfaceView.cocos2dxEditText == textView && isFullScreenEdit) {
|
val fullscreenIme = cocos2dxGLSurfaceView.cocos2dxEditText == textView && isFullScreenEdit
|
||||||
for (i in originText!!.length downTo 1) {
|
if (fullscreenIme) {
|
||||||
|
var text = textView.text.toString()
|
||||||
|
if (originText != text || !cocos2dxGLSurfaceView.sendNewCocosEvents) {
|
||||||
|
if (cocos2dxGLSurfaceView.sendNewCocosEvents) {
|
||||||
|
cocos2dxGLSurfaceView.queueEvent {
|
||||||
|
JniToCpp.textInputCaretToEnd()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repeat(originText!!.length) {
|
||||||
cocos2dxGLSurfaceView.deleteBackward()
|
cocos2dxGLSurfaceView.deleteBackward()
|
||||||
}
|
}
|
||||||
var text = textView.text.toString()
|
|
||||||
if (text.compareTo("") == 0) {
|
if (!cocos2dxGLSurfaceView.sendNewCocosEvents) {
|
||||||
text = "\n"
|
if (!text.endsWith('\n')) {
|
||||||
|
text += '\n'
|
||||||
}
|
}
|
||||||
if (10 != text[text.length - 1].code) {
|
|
||||||
text += 10.toChar()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (text.isNotEmpty()) {
|
||||||
cocos2dxGLSurfaceView.insertText(text)
|
cocos2dxGLSurfaceView.insertText(text)
|
||||||
}
|
}
|
||||||
if (actionID != 6) {
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullscreenIme || actionID == EditorInfo.IME_ACTION_DONE) {
|
||||||
|
if (cocos2dxGLSurfaceView.sendNewCocosEvents) {
|
||||||
|
cocos2dxGLSurfaceView.queueEvent {
|
||||||
|
JniToCpp.textInputDone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionID == EditorInfo.IME_ACTION_DONE) {
|
||||||
cocos2dxGLSurfaceView.requestFocus()
|
cocos2dxGLSurfaceView.requestFocus()
|
||||||
Cocos2dxGLSurfaceView.closeIMEKeyboard()
|
Cocos2dxGLSurfaceView.closeIMEKeyboard()
|
||||||
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,8 +10,9 @@ object Cocos2dxTypefaces {
|
|||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
operator fun get(pContext: Context, pAssetName: String): Typeface? {
|
operator fun get(pContext: Context, pAssetName: String): Typeface? {
|
||||||
var typeface: Typeface?
|
val typeface: Typeface?
|
||||||
var typeface2: Typeface
|
val typeface2: Typeface
|
||||||
|
|
||||||
synchronized(Cocos2dxTypefaces::class.java) {
|
synchronized(Cocos2dxTypefaces::class.java) {
|
||||||
if (!typefaceCache.containsKey(pAssetName)) {
|
if (!typefaceCache.containsKey(pAssetName)) {
|
||||||
typeface2 = if (pAssetName.startsWith("/")) {
|
typeface2 = if (pAssetName.startsWith("/")) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import android.content.Intent
|
|||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.content.res.AssetManager
|
import android.content.res.AssetManager
|
||||||
|
import android.media.AudioDeviceCallback
|
||||||
import android.media.AudioDeviceInfo
|
import android.media.AudioDeviceInfo
|
||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
@@ -14,27 +15,45 @@ import android.os.Build
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import java.io.FileNotFoundException
|
import java.io.FileNotFoundException
|
||||||
|
|
||||||
|
|
||||||
// i never agreed to your licenses! take that
|
// i never agreed to your licenses! take that
|
||||||
@SuppressLint("StaticFieldLeak")
|
@SuppressLint("StaticFieldLeak")
|
||||||
object FMOD {
|
object FMOD {
|
||||||
private var gContext: Context? = null
|
private var gContext: Context? = null
|
||||||
|
private var gPluginAudioDeviceCallback: PluginAudioDeviceCallback? = null
|
||||||
private val gPluginBroadcastReceiver = PluginBroadcastReceiver()
|
private val gPluginBroadcastReceiver = PluginBroadcastReceiver()
|
||||||
|
|
||||||
external fun OutputAAudioHeadphonesChanged()
|
external fun OutputAAudioHeadphonesChanged()
|
||||||
|
external fun SetInputEnumerationChanged()
|
||||||
|
external fun SetOutputEnumerationChanged()
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun init(context: Context?) {
|
fun init(context: Context?) {
|
||||||
gContext = context
|
gContext = context
|
||||||
gContext?.registerReceiver(
|
if (context == null) {
|
||||||
gPluginBroadcastReceiver,
|
return
|
||||||
IntentFilter(Intent.ACTION_HEADSET_PLUG)
|
}
|
||||||
)
|
|
||||||
|
val intentFilter = IntentFilter(Intent.ACTION_HEADSET_PLUG)
|
||||||
|
if (Build.VERSION.SDK_INT >= 34) {
|
||||||
|
context.registerReceiver(gPluginBroadcastReceiver, intentFilter, Context.RECEIVER_NOT_EXPORTED)
|
||||||
|
} else {
|
||||||
|
context.registerReceiver(gPluginBroadcastReceiver, intentFilter)
|
||||||
|
}
|
||||||
|
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
|
val pluginAudioDeviceCallback = PluginAudioDeviceCallback(audioManager.getDevices(3))
|
||||||
|
gPluginAudioDeviceCallback = pluginAudioDeviceCallback
|
||||||
|
audioManager.registerAudioDeviceCallback(pluginAudioDeviceCallback, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun close() {
|
fun close() {
|
||||||
val context = gContext
|
val context = gContext
|
||||||
context?.unregisterReceiver(gPluginBroadcastReceiver)
|
if (context != null) {
|
||||||
|
context.unregisterReceiver(gPluginBroadcastReceiver)
|
||||||
|
(context.getSystemService(Context.AUDIO_SERVICE) as AudioManager)
|
||||||
|
.unregisterAudioDeviceCallback(gPluginAudioDeviceCallback)
|
||||||
|
}
|
||||||
gContext = null
|
gContext = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +128,12 @@ object FMOD {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun getAudioDevices(i: Int): Array<AudioDeviceInfo?>? {
|
||||||
|
val context = gContext ?: return emptyArray()
|
||||||
|
return (context.getSystemService(Context.AUDIO_SERVICE) as AudioManager).getDevices(i)
|
||||||
|
}
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun fileDescriptorFromUri(str: String?): Int {
|
fun fileDescriptorFromUri(str: String?): Int {
|
||||||
gContext?.apply {
|
gContext?.apply {
|
||||||
@@ -127,4 +152,82 @@ object FMOD {
|
|||||||
OutputAAudioHeadphonesChanged()
|
OutputAAudioHeadphonesChanged()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal class PluginAudioDeviceCallback : AudioDeviceCallback {
|
||||||
|
val deviceSet: HashSet<Int> = HashSet()
|
||||||
|
|
||||||
|
constructor(audioDeviceInfoArr: Array<out AudioDeviceInfo?>?) {
|
||||||
|
if (audioDeviceInfoArr != null) {
|
||||||
|
deviceSet.addAll(audioDeviceInfoArr.mapNotNull { it?.id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAudioDevicesAdded(audioDeviceInfoArr: Array<out AudioDeviceInfo?>?) {
|
||||||
|
var z: Boolean
|
||||||
|
var i = 0
|
||||||
|
if (audioDeviceInfoArr != null) {
|
||||||
|
var i2 = 0
|
||||||
|
z = false
|
||||||
|
while (i < audioDeviceInfoArr.size) {
|
||||||
|
if (!deviceSet.contains(audioDeviceInfoArr[i]!!.id)) {
|
||||||
|
if (audioDeviceInfoArr[i]!!.isSource) {
|
||||||
|
i2 = 1
|
||||||
|
}
|
||||||
|
if (audioDeviceInfoArr[i]!!.isSink) {
|
||||||
|
z = true
|
||||||
|
}
|
||||||
|
deviceSet.add(audioDeviceInfoArr[i]!!.id)
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
i = i2
|
||||||
|
} else {
|
||||||
|
z = false
|
||||||
|
}
|
||||||
|
if (i != 0) {
|
||||||
|
try {
|
||||||
|
SetInputEnumerationChanged()
|
||||||
|
} catch (_: UnsatisfiedLinkError) { }
|
||||||
|
}
|
||||||
|
if (z) {
|
||||||
|
try {
|
||||||
|
SetOutputEnumerationChanged()
|
||||||
|
} catch (_: UnsatisfiedLinkError) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAudioDevicesRemoved(audioDeviceInfoArr: Array<out AudioDeviceInfo?>?) {
|
||||||
|
var z: Boolean
|
||||||
|
var i = 0
|
||||||
|
if (audioDeviceInfoArr != null) {
|
||||||
|
var i2 = 0
|
||||||
|
z = false
|
||||||
|
while (i < audioDeviceInfoArr.size) {
|
||||||
|
if (deviceSet.contains(audioDeviceInfoArr[i]!!.id)) {
|
||||||
|
if (audioDeviceInfoArr[i]!!.isSource) {
|
||||||
|
i2 = 1
|
||||||
|
}
|
||||||
|
if (audioDeviceInfoArr[i]!!.isSink) {
|
||||||
|
z = true
|
||||||
|
}
|
||||||
|
deviceSet.remove(audioDeviceInfoArr[i]!!.id)
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
i = i2
|
||||||
|
} else {
|
||||||
|
z = false
|
||||||
|
}
|
||||||
|
if (i != 0) {
|
||||||
|
try {
|
||||||
|
SetInputEnumerationChanged()
|
||||||
|
} catch (_: UnsatisfiedLinkError) { }
|
||||||
|
}
|
||||||
|
if (z) {
|
||||||
|
try {
|
||||||
|
SetOutputEnumerationChanged()
|
||||||
|
} catch (_: UnsatisfiedLinkError) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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,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>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="1366"
|
||||||
|
android:viewportHeight="1366">
|
||||||
|
<group>
|
||||||
|
<clip-path
|
||||||
|
android:pathData="M0,0h1366v1366h-1366z"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M0,0h1366v1366h-1366z"
|
||||||
|
android:fillColor="#F4D48E"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M598,683m-512,0a512,512 0,1 1,1024 0a512,512 0,1 1,-1024 0"
|
||||||
|
android:fillColor="#F4D48E"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M873.8,459.7C752.5,412.3 642.7,323 397.2,323C151.8,323 18.6,418.7 4.3,463.8C-10.1,508.8 4.7,562.4 -1.7,561.4L-25,878H1382L1373,631.1C1346.8,597.8 1260.8,569.9 1217.2,562.4C1173.6,554.9 996.8,507.8 873.8,459.7Z"
|
||||||
|
android:fillColor="#F4A47B"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M647.9,685.4C569.9,647.4 412.4,573 277.3,573C142.1,573 80.5,611.7 39.1,632.4C-2.4,653.2 -9.5,656.1 -14.9,657.4L-25,978.5L650,985L647.9,685.4Z"
|
||||||
|
android:fillColor="#E27880"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M1362.1,728.7C1327,696.7 1194.3,624.7 888.5,623C725.4,622.1 492.3,679.9 354.7,757.1C228.9,827.6 68.9,867 -5.2,873.5L-152,1140H1583L1362.1,728.7Z"
|
||||||
|
android:fillColor="#B9588F"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M1107,1027.2C885.5,1027.2 739,943.6 544.9,943.6C350.9,943.6 228.8,1031.7 186.1,1040.2L-8,1120.8V1385L1376,1379L1367,909C1361.5,929 1287.6,1027.2 1107,1027.2Z"
|
||||||
|
android:fillColor="#714A9A"/>
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="1366"
|
||||||
|
android:viewportHeight="1366">
|
||||||
|
<group>
|
||||||
|
<clip-path
|
||||||
|
android:pathData="M0,0h1366v1366h-1366z"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M0,0h1366v1366h-1366z"
|
||||||
|
android:fillColor="#9EF4C3"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M598,683m-512,0a512,512 0,1 1,1024 0a512,512 0,1 1,-1024 0"
|
||||||
|
android:fillColor="#9EF4C3"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M873.8,459.7C752.5,412.3 642.7,323 397.2,323C151.8,323 18.6,418.7 4.3,463.8C-10.1,508.8 4.7,562.4 -1.7,561.4L-25,878H1382L1373,631.1C1346.8,597.8 1260.8,569.9 1217.2,562.4C1173.6,554.9 996.8,507.8 873.8,459.7Z"
|
||||||
|
android:fillColor="#6DDBAD"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M647.9,685.4C569.9,647.4 412.4,573 277.3,573C142.1,573 80.5,611.7 39.1,632.4C-2.4,653.2 -9.5,656.1 -14.9,657.4L-25,978.5L650,985L647.9,685.4Z"
|
||||||
|
android:fillColor="#64C780"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M1362.1,728.7C1327,696.7 1194.3,624.7 888.5,623C725.4,622.1 492.3,679.9 354.7,757.1C228.9,827.6 68.9,867 -5.2,873.5L-152,1140H1583L1362.1,728.7Z"
|
||||||
|
android:fillColor="#46A47D"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M1107,1027.2C885.5,1027.2 739,943.6 544.9,943.6C350.9,943.6 228.8,1031.7 186.1,1040.2L-8,1120.8V1385L1376,1379L1367,909C1361.5,929 1287.6,1027.2 1107,1027.2Z"
|
||||||
|
android:fillColor="#417563"/>
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="1366"
|
||||||
|
android:viewportHeight="1366">
|
||||||
|
<group>
|
||||||
|
<clip-path
|
||||||
|
android:pathData="M0,0h1366v1366h-1366z"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M0,0h1366v1366h-1366z"
|
||||||
|
android:fillColor="#CC7C7C"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M598,683m-512,0a512,512 0,1 1,1024 0a512,512 0,1 1,-1024 0"
|
||||||
|
android:fillColor="#CC7C7C"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M873.8,459.7C752.5,412.3 642.7,323 397.2,323C151.8,323 18.6,418.7 4.3,463.8C-10.1,508.8 4.7,562.4 -1.7,561.4L-25,878H1382L1373,631.1C1346.8,597.8 1260.8,569.9 1217.2,562.4C1173.6,554.9 996.8,507.8 873.8,459.7Z"
|
||||||
|
android:fillColor="#C6B471"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M647.9,685.4C569.9,647.4 412.4,573 277.3,573C142.1,573 80.5,611.7 39.1,632.4C-2.4,653.2 -9.5,656.1 -14.9,657.4L-25,978.5L650,985L647.9,685.4Z"
|
||||||
|
android:fillColor="#59AF7F"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M1362.1,728.7C1327,696.7 1194.3,624.7 888.5,623C725.4,622.1 492.3,679.9 354.7,757.1C228.9,827.6 68.9,867 -5.2,873.5L-152,1140H1583L1362.1,728.7Z"
|
||||||
|
android:fillColor="#5F8CAB"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M1107,1027.2C885.5,1027.2 739,943.6 544.9,943.6C350.9,943.6 228.8,1031.7 186.1,1040.2L-8,1120.8V1385L1376,1379L1367,909C1361.5,929 1287.6,1027.2 1107,1027.2Z"
|
||||||
|
android:fillColor="#70558D"/>
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="1024"
|
||||||
|
android:viewportHeight="1024">
|
||||||
|
<path
|
||||||
|
android:pathData="M0,0h1024v1024h-1024z"
|
||||||
|
android:fillColor="#070066"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M512.5,521.1L915.2,638.7L1082,922.6L915.2,1206.4L512.5,1324L109.8,1206.4L-57,922.6L109.8,638.7L512.5,521.1Z"
|
||||||
|
android:fillColor="#6694C8"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M915.2,638.7L1082,922.6V638.7H915.2Z"
|
||||||
|
android:fillColor="#4268B8"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M109.8,638.7L512.5,521.1L109.8,397.2V638.7Z"
|
||||||
|
android:fillColor="#2D5EC7"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M-57,922.6L109.8,638.7H-57V922.6Z"
|
||||||
|
android:fillColor="#4268B8"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M512.5,521.1L915.2,638.7V397.2L512.5,521.1Z"
|
||||||
|
android:fillColor="#2D5EC7"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M-57,638.7H109.8V397.2L-57,638.7Z"
|
||||||
|
android:fillColor="#2248AA"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M109.8,397.2L512.5,521.1V194L109.8,397.2Z"
|
||||||
|
android:fillColor="#020F9D"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M512.5,521.1L915.2,397.2L512.5,194V521.1Z"
|
||||||
|
android:fillColor="#020F9D"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M915.2,638.7H1082L915.2,397.2V638.7Z"
|
||||||
|
android:fillColor="#2248AA"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="643"
|
||||||
|
android:viewportHeight="766">
|
||||||
|
<group android:scaleX="0.41038585"
|
||||||
|
android:scaleY="0.4888889"
|
||||||
|
android:translateX="191.0163"
|
||||||
|
android:translateY="195.75555">
|
||||||
|
<path
|
||||||
|
android:pathData="M299.94,33.78C313.01,25.85 329.41,25.85 342.48,33.78L370.9,51.03L268.17,116.49L268.23,116.51L92.68,223.61C84.64,228.51 79.74,237.25 79.74,246.66V509.84C79.74,519.26 84.64,527.99 92.68,532.89L306.22,663.17C314.85,668.44 325.71,668.44 334.34,663.17L547.89,532.89C555.92,527.99 560.82,519.26 560.82,509.85V246.66C560.82,246.2 560.81,245.74 560.78,245.28V166.26L599.75,189.9C612,197.34 619.48,210.63 619.48,224.96V540.81C619.48,555.14 612,568.42 599.75,575.86L342.48,731.98C329.41,739.91 313.01,739.91 299.94,731.98L42.66,575.86C30.41,568.42 22.93,555.14 22.93,540.81V224.96C22.93,210.63 30.41,197.34 42.66,189.9L299.94,33.78Z"
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:fillAlpha="0.15"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M311.75,173.17C318.78,168.88 327.62,168.88 334.65,173.17L481.88,262.87C488.43,266.86 492.43,273.98 492.43,281.66V463.16C492.43,470.84 488.43,477.96 481.88,481.95L334.65,571.65C327.62,575.94 318.78,575.94 311.75,571.65L164.52,481.95C157.97,477.96 153.97,470.84 153.97,463.16V414.95L208.24,448.54C208.52,448.75 208.8,448.95 209.1,449.13L315.66,515.36C319.22,517.57 323.72,517.57 327.28,515.36L433.84,449.13C437.07,447.13 439.03,443.59 439.03,439.79V305.9C439.03,302.09 437.07,298.56 433.84,296.55L327.28,230.33C323.72,228.12 319.22,228.12 315.66,230.33L209.1,296.55C208.62,296.85 208.17,297.18 207.75,297.55L153.97,331.28V281.66C153.97,273.98 157.97,266.86 164.52,262.87L311.75,173.17Z"
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:fillAlpha="0.15"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M299.94,5.95C313.01,-1.98 329.41,-1.98 342.48,5.95L374.73,25.52L264.09,96.01L264.15,96.03L74.11,211.97C66.07,216.88 61.17,225.61 61.17,235.02V520.77C61.17,530.18 66.07,538.92 74.11,543.82L306.15,685.38C314.78,690.65 325.64,690.65 334.27,685.38L566.31,543.82C574.34,538.92 579.25,530.18 579.25,520.77V235.02C579.25,234.53 579.23,234.05 579.2,233.56V149.6L622.68,175.99C634.93,183.42 642.41,196.71 642.41,211.04V554.72C642.41,569.05 634.93,582.34 622.68,589.78L342.48,759.82C329.41,767.75 313.01,767.75 299.94,759.82L19.73,589.78C7.48,582.34 0,569.05 0,554.72V211.04C0,196.71 7.48,183.42 19.73,175.99L299.94,5.95Z"
|
||||||
|
android:fillColor="#ffffff"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M311.75,142.59C318.78,138.31 327.62,138.31 334.65,142.59L507.79,248.08C514.34,252.08 518.34,259.2 518.34,266.87V479.95C518.34,487.62 514.34,494.74 507.79,498.74L334.65,604.23C327.62,608.51 318.78,608.51 311.75,604.23L138.61,498.74C132.06,494.74 128.06,487.62 128.06,479.95V422.46L189.93,460.76C190.22,460.98 190.52,461.19 190.84,461.39L315.4,538.8C318.95,541.01 323.46,541.01 327.01,538.8L451.58,461.39C454.8,459.38 456.77,455.85 456.77,452.04V295.77C456.77,291.97 454.8,288.44 451.58,286.43L327.01,209.02C323.46,206.81 318.95,206.81 315.4,209.02L190.84,286.43C190.32,286.76 189.83,287.12 189.37,287.52L128.06,325.98V266.87C128.06,259.2 132.06,252.08 138.61,248.08L311.75,142.59Z"
|
||||||
|
android:fillColor="#ffffff"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="643"
|
||||||
|
android:viewportHeight="766">
|
||||||
|
<group android:scaleX="0.41038585"
|
||||||
|
android:scaleY="0.4888889"
|
||||||
|
android:translateX="191.0163"
|
||||||
|
android:translateY="195.75555">
|
||||||
|
<path
|
||||||
|
android:pathData="M299.94,5.95C313.01,-1.98 329.41,-1.98 342.48,5.95L374.73,25.52L264.09,96.01L264.15,96.03L74.11,211.97C66.07,216.88 61.17,225.61 61.17,235.02V520.77C61.17,530.18 66.07,538.92 74.11,543.82L306.15,685.38C314.78,690.65 325.64,690.65 334.27,685.38L566.31,543.82C574.34,538.92 579.25,530.18 579.25,520.77V235.02C579.25,234.53 579.23,234.05 579.2,233.56V149.6L622.68,175.99C634.93,183.42 642.41,196.71 642.41,211.04V554.72C642.41,569.05 634.93,582.34 622.68,589.78L342.48,759.82C329.41,767.75 313.01,767.75 299.94,759.82L19.73,589.78C7.48,582.34 0,569.05 0,554.72V211.04C0,196.71 7.48,183.42 19.73,175.99L299.94,5.95Z"
|
||||||
|
android:fillColor="#ffffff"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M311.75,142.59C318.78,138.31 327.62,138.31 334.65,142.59L507.79,248.08C514.34,252.08 518.34,259.2 518.34,266.87V479.95C518.34,487.62 514.34,494.74 507.79,498.74L334.65,604.23C327.62,608.51 318.78,608.51 311.75,604.23L138.61,498.74C132.06,494.74 128.06,487.62 128.06,479.95V422.46L189.93,460.76C190.22,460.98 190.52,461.19 190.84,461.39L315.4,538.8C318.95,541.01 323.46,541.01 327.01,538.8L451.58,461.39C454.8,459.38 456.77,455.85 456.77,452.04V295.77C456.77,291.97 454.8,288.44 451.58,286.43L327.01,209.02C323.46,206.81 318.95,206.81 315.4,209.02L190.84,286.43C190.32,286.76 189.83,287.12 189.37,287.52L128.06,325.98V266.87C128.06,259.2 132.06,252.08 138.61,248.08L311.75,142.59Z"
|
||||||
|
android:fillColor="#ffffff"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="1366"
|
||||||
|
android:viewportHeight="1366">
|
||||||
|
<group>
|
||||||
|
<clip-path
|
||||||
|
android:pathData="M0,0h1366v1366h-1366z"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M0,0h1366v1366h-1366z"
|
||||||
|
android:fillColor="#65B8C2"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M598,683m-512,0a512,512 0,1 1,1024 0a512,512 0,1 1,-1024 0"
|
||||||
|
android:fillColor="#65B8C2"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M873.8,459.7C752.5,412.3 642.7,323 397.2,323C151.8,323 18.6,418.7 4.3,463.8C-10.1,508.8 4.7,562.4 -1.7,561.4L-25,878H1382L1373,631.1C1346.8,597.8 1260.8,569.9 1217.2,562.4C1173.6,554.9 996.8,507.8 873.8,459.7Z"
|
||||||
|
android:fillColor="#DEA5CD"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M647.9,685.4C569.9,647.4 412.4,573 277.3,573C142.1,573 80.5,611.7 39.1,632.4C-2.4,653.2 -9.5,656.1 -14.9,657.4L-25,978.5L650,985L647.9,685.4Z"
|
||||||
|
android:fillColor="#DEE8EA"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M1362.1,728.7C1327,696.7 1194.3,624.7 888.5,623C725.4,622.1 492.3,679.9 354.7,757.1C228.9,827.6 68.9,867 -5.2,873.5L-152,1140H1583L1362.1,728.7Z"
|
||||||
|
android:fillColor="#C485BD"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M1107,1027.2C885.5,1027.2 739,943.6 544.9,943.6C350.9,943.6 228.8,1031.7 186.1,1040.2L-8,1120.8V1385L1376,1379L1367,909C1361.5,929 1287.6,1027.2 1107,1027.2Z"
|
||||||
|
android:fillColor="#259CB4"/>
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M440,520L200,520L200,440L440,440L440,200L520,200L520,440L760,440L760,520L520,520L520,760L440,760L440,520Z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960"
|
||||||
|
android:tint="?attr/colorControlNormal"
|
||||||
|
android:autoMirrored="true">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M313,520L537,744L480,800L160,480L480,160L537,216L313,440L800,440L800,520L313,520Z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="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,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>
|
||||||
@@ -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,680L280,680Q197,680 138.5,621.5Q80,563 80,480Q80,397 138.5,338.5Q197,280 280,280L440,280L440,360L280,360Q230,360 195,395Q160,430 160,480Q160,530 195,565Q230,600 280,600L440,600L440,680ZM320,520L320,440L640,440L640,520L320,520ZM520,680L520,600L680,600Q730,600 765,565Q800,530 800,480Q800,430 765,395Q730,360 680,360L520,360L520,280L680,280Q763,280 821.5,338.5Q880,397 880,480Q880,563 821.5,621.5Q763,680 680,680L520,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="M280,840L280,840Q280,840 280,840Q280,840 280,840L280,120Q280,120 280,120Q280,120 280,120L280,120Q280,120 280,120Q280,120 280,120L280,520L280,520Q280,520 280,590Q280,660 280,760L280,840ZM508.5,228.5Q520,217 520,200Q520,183 508.5,171.5Q497,160 480,160Q463,160 451.5,171.5Q440,183 440,200Q440,217 451.5,228.5Q463,240 480,240Q497,240 508.5,228.5ZM280,920Q247,920 223.5,896.5Q200,873 200,840L200,120Q200,87 223.5,63.5Q247,40 280,40L680,40Q713,40 736.5,63.5Q760,87 760,120L760,244Q778,251 789,266Q800,281 800,300L800,380Q800,399 789,414Q778,429 760,436L760,520L680,520L680,120Q680,120 680,120Q680,120 680,120L280,120Q280,120 280,120Q280,120 280,120L280,840Q280,840 280,840Q280,840 280,840L360,840L360,920L280,920ZM584,904L440,760L584,616L640,673L553,760L640,847L584,904ZM776,904L720,847L807,760L720,673L776,616L920,760L776,904Z"/>
|
||||||
|
</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,800Q447,800 423.5,776.5Q400,753 400,720Q400,687 423.5,663.5Q447,640 480,640Q513,640 536.5,663.5Q560,687 560,720Q560,753 536.5,776.5Q513,800 480,800ZM480,560Q447,560 423.5,536.5Q400,513 400,480Q400,447 423.5,423.5Q447,400 480,400Q513,400 536.5,423.5Q560,447 560,480Q560,513 536.5,536.5Q513,560 480,560ZM480,320Q447,320 423.5,296.5Q400,273 400,240Q400,207 423.5,183.5Q447,160 480,160Q513,160 536.5,183.5Q560,207 560,240Q560,273 536.5,296.5Q513,320 480,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="M480,480Q414,480 367,433Q320,386 320,320Q320,254 367,207Q414,160 480,160Q546,160 593,207Q640,254 640,320Q640,386 593,433Q546,480 480,480ZM160,800L160,688Q160,654 177.5,625.5Q195,597 224,582Q286,551 350,535.5Q414,520 480,520Q546,520 610,535.5Q674,551 736,582Q765,597 782.5,625.5Q800,654 800,688L800,800L160,800ZM240,720L720,720L720,688Q720,677 714.5,668Q709,659 700,654Q646,627 591,613.5Q536,600 480,600Q424,600 369,613.5Q314,627 260,654Q251,659 245.5,668Q240,677 240,688L240,720ZM480,400Q513,400 536.5,376.5Q560,353 560,320Q560,287 536.5,263.5Q513,240 480,240Q447,240 423.5,263.5Q400,287 400,320Q400,353 423.5,376.5Q447,400 480,400ZM480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320ZM480,720L480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720L480,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="M720,560L720,440L600,440L600,360L720,360L720,240L800,240L800,360L920,360L920,440L800,440L800,560L720,560ZM360,480Q294,480 247,433Q200,386 200,320Q200,254 247,207Q294,160 360,160Q426,160 473,207Q520,254 520,320Q520,386 473,433Q426,480 360,480ZM40,800L40,688Q40,654 57.5,625.5Q75,597 104,582Q166,551 230,535.5Q294,520 360,520Q426,520 490,535.5Q554,551 616,582Q645,597 662.5,625.5Q680,654 680,688L680,800L40,800ZM120,720L600,720L600,688Q600,677 594.5,668Q589,659 580,654Q526,627 471,613.5Q416,600 360,600Q304,600 249,613.5Q194,627 140,654Q131,659 125.5,668Q120,677 120,688L120,720ZM360,400Q393,400 416.5,376.5Q440,353 440,320Q440,287 416.5,263.5Q393,240 360,240Q327,240 303.5,263.5Q280,287 280,320Q280,353 303.5,376.5Q327,400 360,400ZM360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320ZM360,720L360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720L360,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="M320,760L320,200L760,480L320,760ZM400,480L400,480L400,480ZM400,614L610,480L400,346L400,614Z"/>
|
||||||
|
</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,800Q346,800 253,707Q160,614 160,480Q160,346 253,253Q346,160 480,160Q549,160 612,188.5Q675,217 720,270L720,160L800,160L800,440L520,440L520,360L688,360Q656,304 600.5,272Q545,240 480,240Q380,240 310,310Q240,380 240,480Q240,580 310,650Q380,720 480,720Q557,720 619,676Q681,632 706,560L790,560Q762,666 676,733Q590,800 480,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="M200,520L200,440L760,440L760,520L200,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="M500,840L444,784L586,642L444,500L500,444L642,586L784,444L840,500L698,642L840,784L784,840L642,698L500,840ZM280,840L280,760L360,760L360,840L280,840ZM200,200L120,200Q120,167 143.5,143.5Q167,120 200,120L200,200ZM280,200L280,120L360,120L360,200L280,200ZM440,200L440,120L520,120L520,200L440,200ZM600,200L600,120L680,120L680,200L600,200ZM760,200L760,120Q793,120 816.5,143.5Q840,167 840,200L760,200ZM200,760L200,840Q167,840 143.5,816.5Q120,793 120,760L200,760ZM120,680L120,600L200,600L200,680L120,680ZM120,520L120,440L200,440L200,520L120,520ZM120,360L120,280L200,280L200,360L120,360ZM760,360L760,280L840,280L840,360L760,360Z"/>
|
||||||
|
</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="M370,880L354,752Q341,747 329.5,740Q318,733 307,725L188,775L78,585L181,507Q180,500 180,493.5Q180,487 180,480Q180,473 180,466.5Q180,460 181,453L78,375L188,185L307,235Q318,227 330,220Q342,213 354,208L370,80L590,80L606,208Q619,213 630.5,220Q642,227 653,235L772,185L882,375L779,453Q780,460 780,466.5Q780,473 780,480Q780,487 780,493.5Q780,500 778,507L881,585L771,775L653,725Q642,733 630,740Q618,747 606,752L590,880L370,880ZM440,800L519,800L533,694Q564,686 590.5,670.5Q617,655 639,633L738,674L777,606L691,541Q696,527 698,511.5Q700,496 700,480Q700,464 698,448.5Q696,433 691,419L777,354L738,286L639,328Q617,305 590.5,289.5Q564,274 533,266L520,160L441,160L427,266Q396,274 369.5,289.5Q343,305 321,327L222,286L183,354L269,418Q264,433 262,448Q260,463 260,480Q260,496 262,511Q264,526 269,541L183,606L222,674L321,632Q343,655 369.5,670.5Q396,686 427,694L440,800ZM482,620Q540,620 581,579Q622,538 622,480Q622,422 581,381Q540,340 482,340Q423,340 382.5,381Q342,422 342,480Q342,538 382.5,579Q423,620 482,620ZM480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480Z"/>
|
||||||
|
</vector>
|
||||||
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user