Compare commits

...
110 Commits
Author SHA1 Message Date
Chloe 1658072b1a have directoryprovider follow the selected theme 2026-10-18 18:53:16 -05:00
Chloe b6e88b20cb go green 2026-10-18 18:43:44 -05:00
Chloe f4af50b2cb cleanup keyboard event handler 2026-10-18 16:47:02 -05:00
Chloe 00c47f4c70 add edges to the custom icons 2026-10-18 00:26:08 -05:00
Chloe 11649a236a fix launcher update shadow clipping 2026-10-17 22:15:36 -05:00
Chloe ff712196cc technically 2026-10-17 17:57:34 -05:00
Chloe 6c57f3f261 fix keyboard insets attempting to use new apis 2026-10-17 17:54:11 -05:00
Chloe 626f8d00d1 Merge branch 'orchid-pruning' 2026-10-17 17:49:47 -05:00
Chloe 45ecc71749 update dependencies 2026-10-17 17:49:39 -05:00
Chloe 3d60bd3d53 do textinputwrapper changes 2026-10-17 02:51:06 -05:00
Chloe e40af4c49b implement new file pickers 2026-10-17 02:51:06 -05:00
Chloe e0a31694af i accidentally defaulted it to true 2026-10-17 02:51:05 -05:00
Chloe c8994c2484 add restart with safe mode util 2026-10-17 02:51:05 -05:00
Chloe 35c8428f08 i accidentally defaulted it to true 2026-10-16 17:52:38 -05:00
Chloe bca761f26a add restart with safe mode util 2026-10-16 17:48:35 -05:00
Chloe 79c06ca629 ime and input changes 2026-10-15 22:16:23 -05:00
Chloe 3194dff7ff Bundle Geode with Play Store build (#88)
* add gradle task to download geode binaries

* disable automatic update on play build

* get download url from release api

* fix incorrect priorities between external library and prebundled

* bundle geode resources
this also fixes the release api fetch as i never tested it until now, oops

* add message for loading from files dir

* raise minimum gd version with bundled geode

* clear old resources prior to copying
2026-09-07 17:19:12 -07:00
Chloe 9e86bbe355 idk where this import went 2026-09-07 00:45:13 -07:00
Chloe eef9160970 figure out how to update (almost) everything 2026-09-07 00:40:21 -07:00
Chloe 3b4951def1 implement new release api features (#86)
* implement new release api features

* make hash nullable
2026-08-29 17:06:45 -07:00
Chloe ef44d48ed0 light cleanup of cocos2dxbitmap while i'm at it 2026-08-01 22:56:53 -07:00
Chloe aae2aa2290 fix incorrect behavior in splitString 2026-08-01 22:54:42 -07:00
Oleksandr Nemesh 0a62082519 fix app icon not getting switched on google play (#84) 2026-08-01 04:47:45 +03:00
prevter 758d5ad432 remove some unused permissions
Note: I've tested it a bit and didn't find any problems. This change was required by Google Play to proceed with review
2026-07-05 21:53:55 +03:00
prevter b3a776ce7c fix openFolder util crashing 2026-07-05 21:52:25 +03:00
prevter 72f74fa084 more google play changes 2026-07-01 11:46:54 +03:00
Oleksandr Nemesh 0dba4f0606 add google play support (#82) 2026-06-26 14:28:12 -07:00
Chloe 2016aa4e90 android 17 fix 2026-06-23 19:04:08 -07:00
Chloe 38bc97b7b6 update actions to clear deprecation warnings 2026-04-07 13:04:07 -07:00
Chloe 0a4f13789f fallback to copy if resources rename fails 2026-04-06 04:16:07 -07:00
Chloe 3cb0998274 remove april check completely 2026-04-06 02:15:20 -07:00
Chloe 5d70cb484f update preparation 2026-04-05 23:34:17 -07:00
Chloe a27e67b41e add monochrome icon for sapphire 2026-04-05 23:34:07 -07:00
Chloe 8bff33c013 implement resize callback 2026-04-05 23:03:01 -07:00
Chloe c9c51f0032 remove cutout padding if removed at runtime 2026-04-05 22:54:25 -07:00
Chloe 82b6cf309f fix opening safe mode after crash 2026-04-05 22:49:04 -07:00
Chloe a7654cfc69 add more configuration changes 2026-04-03 18:22:10 -07:00
Chloe c1d136c0cc disable automatic sapphiring 2026-04-03 17:53:37 -07:00
Chloe 269f2fbd02 some release download state fixes 2026-04-01 14:17:57 -07:00
Chloe 21c3374248 remove the other symbol too 2026-04-01 14:04:05 -07:00
Chloe 120f412986 remove symbol from remove list 2026-04-01 13:53:01 -07:00
Chloe 633543f363 1.7.5 2026-04-01 13:05:33 -07:00
Chloe d6b4157cdc fix icon size for animated logo parts 2026-04-01 13:00:32 -07:00
Chloe 482e3bc5d0 fix overflow 2026-04-01 12:58:23 -07:00
Chloe 15e24b566e minimize sapphire font
it was like 2mb for some reason
2026-04-01 03:24:29 -07:00
Chloe 0de077ec1d safe mode button was also broken lol 2026-04-01 03:12:47 -07:00
Chloe 655587e1ee use the proper logo 2026-04-01 03:08:12 -07:00
Chloe d3ad272ffc more sapphire changes 2026-04-01 02:41:23 -07:00
Chloe 79632c9844 fix return to main when application icon is in use 2026-04-01 01:50:01 -07:00
Chloe 8f5b1b4218 add april fools check to sapphire 2026-03-31 22:22:45 -07:00
Chloe 23ed997640 perform resources download within launcher 2026-03-31 22:14:04 -07:00
Chloe acaff15d4d release preparations 2026-03-31 21:22:42 -07:00
Chloe 669ea87be0 add sapphire icon 2026-03-31 21:05:57 -07:00
Chloe b668e5032b warn on below 2.208 2026-03-31 21:05:06 -07:00
Chloe 4508abc155 prompt user to view crash in error dialog 2026-03-21 23:16:16 -07:00
Chloe 38a994436f move geode logs button to settings 2026-03-21 23:13:41 -07:00
Chloe 5ab71d6c62 add share button to logs viewer 2026-03-21 23:00:38 -07:00
Chloe 3b88a32e8f they are named logs 2026-03-21 18:36:37 -07:00
Chloe 0ceecbbc72 optimize computeFileHash to avoid large allocation 2026-03-21 18:36:10 -07:00
Chloe 714f750f77 add support for text based crash dumps 2026-03-19 15:25:59 -07:00
km7dev 69188075a3 make open folder follow the directory devs specify (#65)
* this feels hacky but it works

* make the change chloe suggested
2026-03-12 18:50:05 -07:00
Chloe 187d3f9819 add game version to link failed log 2026-03-12 17:38:35 -07:00
Chloe 559cedb0f0 add symbols to blocklist 2026-03-12 17:35:18 -07:00
Chloe 232b0a52ec increment version 2026-02-26 01:45:22 -07:00
Chloe 915c46f05d ok this is the last one 2026-02-26 01:45:10 -07:00
Chloe a6ceee55d8 we don't need a singleton here 2026-02-25 23:41:27 -07:00
Chloe f2697cfb3b fix size 2026-02-25 23:10:22 -07:00
Chloe 1bb68349e5 split launcher-fix into separate files 2026-02-25 23:02:40 -07:00
Chloe a677cab912 make use of std::format 2026-02-25 21:54:47 -07:00
Chloe 5caa533a4a get rid of dobby
looks like chaos has been waiting for us
2026-02-25 21:32:34 -07:00
Chloe 1a68cc5eb9 reorganize settings utils into package 2026-02-25 17:53:23 -07:00
Chloe ce98734bb9 replace use of createTempFile with manual naming 2026-02-25 14:18:31 -07:00
Chloe f17500ef7b remove android32 from map name 2026-02-25 13:53:42 -07:00
Chloe ea3ed8415b fix variable reference 2026-02-25 03:06:59 -07:00
Chloe 41664e430c gate mappings to release builds 2026-02-25 03:00:48 -07:00
Chloe 8b17e6591f add mappings to github action 2026-02-25 02:44:36 -07:00
Chloe 1d47a54573 use the faster copy i forgot to add 2026-02-25 02:01:45 -07:00
Chloe 9307c74df4 make more things selectable 2026-02-25 01:59:39 -07:00
Chloe df0cebaa89 add geode logs viewer 2026-02-25 01:44:21 -07:00
Chloe 7bab94bf40 crash logs activity is now composable 2026-02-24 15:55:22 -07:00
Chloe 6d5de931fc make crashviewmodel abstract 2026-02-24 15:27:05 -07:00
Chloe c404f977e5 add ability to show update error 2026-02-24 14:50:01 -07:00
Chloe 3202851303 improve resilience of downloadStream 2026-02-24 04:13:59 -07:00
Chloe d1fdb26d44 add another method for the future 2026-02-22 15:30:35 -07:00
Chloe 4987aa00db clarify forwards compatibility message 2026-02-22 15:23:14 -07:00
Chloe 97b1cc288f bump 2026-02-22 15:22:13 -07:00
Chloe 00367096e9 unconditionally update launcher cache 2026-02-21 22:39:07 -07:00
Chloe 0a7a366056 don't update cache on failure 2026-02-21 21:05:14 -07:00
Chloe 60ce6ddee8 remove update without user action permission 2026-02-18 21:06:56 -07:00
Chloe 6c1cc68ad0 only notify launcher update when not visible elsewhere 2026-02-15 20:52:11 -07:00
Chloe b4da272a35 the opposite of the button i added 2026-02-15 20:24:05 -07:00
Chloe 4745a3d36c preparations 2026-02-15 20:14:05 -07:00
Chloe 2bf633922e add user agent to requests 2026-02-15 20:11:19 -07:00
Chloe 02c7399eea fix possible resource leak 2026-02-15 20:10:20 -07:00
Chloe d8985e14cc remove unused settings 2026-02-15 04:48:19 -07:00
Chloe c2125248b6 add method to get version code 2026-02-15 04:32:15 -07:00
Chloe e6bac1e25a isolate launcher download from release fetch 2026-02-15 04:29:08 -07:00
Chloe eab0de3a34 add option to disable update caching 2026-02-15 04:16:51 -07:00
Chloe 9b7d174704 proper initial state check 2026-02-15 04:05:37 -07:00
Chloe 4c0d9b88d2 show notification in forward compatibility 2026-02-06 14:04:49 -07:00
Chloe 1adae94859 clear cache when changing update channel 2026-02-06 12:57:44 -07:00
Chloe 5edd63f78f add a button just for me 2026-02-01 22:52:43 -07:00
Chloe 3a54c34580 possible optimization 2026-02-01 22:34:21 -07:00
Chloe f12f480a38 cache launcher update check 2026-02-01 21:32:57 -07:00
Chloe 1d7cd9594d separate launcher details for update check 2026-02-01 21:24:19 -07:00
Chloe 9d4c27ac92 perform launcher download during update 2026-02-01 21:04:32 -07:00
Chloe 2754cc4bb8 fix closable resource usage 2026-02-01 16:37:59 -07:00
Chloe 0d01f8c74b use android's builtin xml parser
should reduce apk size as the previous parser library was large
2026-02-01 15:41:23 -07:00
Chloe 24dd71a96d add save backup functionality 2026-02-01 04:52:24 -07:00
Chloe 1f1e0de523 switch to okio official coroutines 2026-02-01 01:28:07 -07:00
76 changed files with 5130 additions and 1757 deletions

No files matched your search

+76 -8
View File
@@ -12,17 +12,23 @@ jobs:
config:
- name: Release
lower: release
flavor: standard
- name: Debug
lower: debug
flavor: standard
- name: Release
lower: release
flavor: googlePlay
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Java JDK
uses: actions/setup-java@v4
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: 21
distribution: temurin
@@ -34,9 +40,19 @@ jobs:
- name: Fix gradle permissions
run: chmod +x ./gradlew
- name: Build project
- name: Build project (standard)
if: ${{ matrix.config.flavor == 'standard' }}
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.password="${{secrets.KEYSTORE_PASSWORD}}" \
-Pandroid.injected.signing.key.alias="${{secrets.KEY_ALIAS}}" \
@@ -44,26 +60,78 @@ jobs:
- name: Rename universal apk
id: rename_apk_universal
if: ${{ matrix.config.flavor == 'standard' }}
run: |
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
mv $APK_PATH $OUT_PATH
echo "path=$OUT_PATH" >> $GITHUB_OUTPUT
- name: Rename apk
id: rename_apk
if: ${{ matrix.config.flavor == 'standard' }}
run: |
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
mv $APK_PATH $OUT_PATH
echo "path=$OUT_PATH" >> $GITHUB_OUTPUT
- name: Upload development build
uses: actions/upload-artifact@v4
- name: Rename mappings
id: rename_mappings
if: ${{ matrix.config.flavor == 'standard' && matrix.config.lower == 'release' }}
run: |
SHORT_SHA=${GITHUB_SHA::7}
MAPPING_PATH=${{github.workspace}}/app/build/outputs/mapping/standardRelease/mapping.txt
OUT_PATH=${{github.workspace}}/mapping-${{ matrix.config.lower }}-$SHORT_SHA.txt
mv $MAPPING_PATH $OUT_PATH
echo "path=$OUT_PATH" >> $GITHUB_OUTPUT
- name: Rename AAB
id: rename_aab
if: ${{ matrix.config.flavor == 'googlePlay' }}
run: |
SHORT_SHA=${GITHUB_SHA::7}
AAB_PATH=${{github.workspace}}/app/build/outputs/bundle/googlePlayRelease/app-googlePlay-release.aab
OUT_PATH=${{github.workspace}}/geode-launcher-${{ matrix.config.lower }}-$SHORT_SHA.aab
mv $AAB_PATH $OUT_PATH
echo "path=$OUT_PATH" >> $GITHUB_OUTPUT
- name: Rename AAB mappings
id: rename_aab_mappings
if: ${{ matrix.config.flavor == 'googlePlay' }}
run: |
SHORT_SHA=${GITHUB_SHA::7}
MAPPING_PATH=${{github.workspace}}/app/build/outputs/mapping/googlePlayRelease/mapping.txt
OUT_PATH=${{github.workspace}}/mapping-${{ matrix.config.lower }}-$SHORT_SHA.txt
mv $MAPPING_PATH $OUT_PATH
echo "path=$OUT_PATH" >> $GITHUB_OUTPUT
- name: Upload release build
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: ${{ matrix.config.flavor == 'standard' && matrix.config.lower == 'release' }}
with:
name: geode-launcher-${{ matrix.config.lower }}
path: |
${{ steps.rename_apk_universal.outputs.path }}
${{ steps.rename_apk.outputs.path }}
${{ steps.rename_mappings.outputs.path }}
- name: Upload debug build
uses: actions/upload-artifact@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 }}
+290 -28
View File
@@ -1,6 +1,7 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
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 {
@@ -14,15 +15,241 @@ plugins {
id("org.jetbrains.kotlin.plugin.compose")
}
val currentGeodeVersion = "5.11.2"
abstract class DownloadVersionMetadataTask : DefaultTask() {
@get:Input
abstract val geodeVersion: Property<String>
@get:OutputFile
abstract val targetFile: RegularFileProperty
@TaskAction
fun execute() {
val downloadGeodeVersion = geodeVersion.get()
val destinationFile = targetFile.get().asFile
val releaseUrl = "https://api.geode-sdk.org/v1/loader/versions/$downloadGeodeVersion"
URI.create(releaseUrl).toURL().openStream().use { response ->
destinationFile.outputStream().use { destination ->
response.copyTo(destination)
}
}
}
}
abstract class DownloadGeodeFileTask : DefaultTask() {
@get:Input
abstract val platform: Property<String>
@get:InputFile
abstract val metadata: RegularFileProperty
private fun downloadAndVerifyFile(filename: String, url: String, hash: String?): File {
logger.info("Downloading $filename from $url")
val tempFile = File(temporaryDir, filename)
URI.create(url).toURL().openStream().use { response ->
tempFile.outputStream().use { output ->
response.copyTo(output)
}
}
if (hash != null) {
val downloadedHash = MessageDigest.getInstance("SHA-256")
.digest(tempFile.readBytes())
.toHexString()
if (downloadedHash != hash) {
throw GradleException("Hash check failed for file $filename, expected $hash but found $downloadedHash.")
}
}
return tempFile
}
fun downloadZip(fallbackUrl: String?): File {
val downloadPlatform = platform.get()
val metadataFile = metadata.get().asFile
val json = JsonSlurper().parse(metadataFile) as Map<*, *>
val payload = json["payload"] as? Map<*, *>
val downloads = payload?.get("downloads") as? Map<*, *>
val platformInfo = downloads?.get(downloadPlatform) as? Map<*, *>
val downloadUrl = platformInfo?.get("url") as? String ?: fallbackUrl
if (downloadUrl == null) {
throw GradleException("Failed to fetch download url for platform $platform.")
}
val expectedHash = platformInfo?.get("hash") as? String
return downloadAndVerifyFile("$downloadPlatform.zip", downloadUrl, expectedHash)
}
}
abstract class DownloadAndUnzipGeodeTask : DownloadGeodeFileTask() {
@get:Input
abstract val geodeVersion: Property<String>
@get:OutputFile
abstract val targetFile: RegularFileProperty
@TaskAction
fun execute() {
val downloadGeodeVersion = geodeVersion.get()
val downloadPlatform = platform.get()
val destinationFile = targetFile.get().asFile
val tempOutput = this.downloadZip("https://github.com/geode-sdk/geode/releases/download/v$downloadGeodeVersion/geode-v$downloadGeodeVersion-$downloadPlatform.zip")
val expectedFile = "Geode.$downloadPlatform.so"
ZipInputStream(tempOutput.inputStream()).use { zip ->
var entry = zip.nextEntry
while (entry != null) {
if (entry.name == expectedFile) {
destinationFile.outputStream().use {
zip.copyTo(it)
}
return@execute
}
entry = zip.nextEntry
}
throw GradleException("Could not find Geode release.")
}
}
}
abstract class DownloadAndUnzipGeodeResourcesTask : DownloadGeodeFileTask() {
init {
platform.set("resources")
}
@get:Input
abstract val geodeVersion: Property<String>
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
@TaskAction
fun execute() {
val downloadGeodeVersion = geodeVersion.get()
val tempOutput = this.downloadZip("https://github.com/geode-sdk/geode/releases/download/v$downloadGeodeVersion/resources.zip")
val baseOutput = outputDir.get().asFile
if (baseOutput.exists()) baseOutput.deleteRecursively()
val output = File(baseOutput, "geode.loader")
output.mkdirs()
val canonicalOutput = output.canonicalPath
ZipInputStream(tempOutput.inputStream()).use { zipStream ->
var entry = zipStream.nextEntry
while (entry != null) {
val destination = File(output, entry.name)
if (!destination.canonicalPath.startsWith(canonicalOutput)) {
throw GradleException("attempted copy to outside of output directory: ${entry.name}")
}
if (destination.isDirectory) {
destination.mkdirs()
} else {
destination.parentFile?.mkdirs()
destination.outputStream().use { destinationStream ->
zipStream.copyTo(destinationStream)
}
}
entry = zipStream.nextEntry
}
}
}
}
val fetchGeodeMetadataTask = tasks.register<DownloadVersionMetadataTask>("fetchGeodeMetadata") {
description = "Fetches and saves a Geode version's metadata"
inputs.property("geodeVersion", currentGeodeVersion)
geodeVersion = currentGeodeVersion
targetFile = temporaryDir.resolve("metadata.json")
}
val downloadGeode32Task = tasks.register<DownloadAndUnzipGeodeTask>("downloadGeode32Binary") {
description = "Downloads the 32bit Geode release archive"
inputs.property("geodeVersion", currentGeodeVersion)
geodeVersion = currentGeodeVersion
platform = "android32"
metadata = fetchGeodeMetadataTask.flatMap { it.targetFile }
targetFile = temporaryDir.resolve("libgeode.so")
}
val downloadGeode64Task = tasks.register<DownloadAndUnzipGeodeTask>("downloadGeode64Binary") {
description = "Downloads the 64bit Geode release archive"
inputs.property("geodeVersion", currentGeodeVersion)
geodeVersion = currentGeodeVersion
platform = "android64"
metadata = fetchGeodeMetadataTask.flatMap { it.targetFile }
targetFile = temporaryDir.resolve("libgeode.so")
}
val downloadGeodeResourcesTask = tasks.register<DownloadAndUnzipGeodeResourcesTask>("downloadGeodeResources") {
description = "Downloads Geode resources"
inputs.property("geodeVersion", currentGeodeVersion)
geodeVersion = currentGeodeVersion
metadata = fetchGeodeMetadataTask.flatMap { it.targetFile }
outputDir = layout.buildDirectory.dir("geode-resources")
}
abstract class MergeGeodeFilesTask : DefaultTask() {
@get:InputFile
abstract val binary32: RegularFileProperty
@get:InputFile
abstract val binary64: RegularFileProperty
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
@TaskAction
fun execute() {
val out = outputDir.get().asFile
out.deleteRecursively()
out.mkdirs()
binary32.get().asFile.copyTo(outputDir.file("armeabi-v7a/libgeode.so").get().asFile)
binary64.get().asFile.copyTo(outputDir.file("arm64-v8a/libgeode.so").get().asFile)
}
}
val fullDownloadTask = tasks.register<MergeGeodeFilesTask>("downloadGeodeFull") {
description = "Downloads both Geode releases"
binary32 = downloadGeode32Task.flatMap { it.targetFile }
binary64 = downloadGeode64Task.flatMap { it.targetFile }
outputDir = layout.buildDirectory.dir("geode-prebuilt")
}
android {
compileSdk = 36
compileSdk = 37
defaultConfig {
applicationId = "com.geode.launcher"
minSdk = 23
targetSdk = 36
versionCode = 23
versionName = "1.7.0"
targetSdk = 37
versionCode = 31
versionName = "1.9.0"
vectorDrawables {
useSupportLibrary = true
@@ -31,7 +258,7 @@ android {
@Suppress("UnstableApiUsage")
externalNativeBuild {
cmake {
arguments("-DUSE_TULIPHOOK:BOOL=OFF", "-DANDROID_STL=c++_shared")
arguments("-DANDROID_STL=c++_shared")
}
}
@@ -41,7 +268,7 @@ android {
splits {
abi {
isEnable = true
isEnable = !project.hasProperty("disableAbiSplits")
reset()
//noinspection ChromeOsAbiSupport. i'm sorry!
@@ -51,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 {
release {
isMinifyEnabled = true
@@ -66,8 +311,8 @@ android {
// enables a polyfill for java Instant on api levels < 26 (used for updater)
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures {
compose = true
@@ -80,34 +325,51 @@ android {
}
externalNativeBuild {
cmake {
version = "3.25.0+"
version = "3.21.0+"
path = file("src/main/cpp/CMakeLists.txt")
}
}
namespace = "com.geode.launcher"
ndkVersion = "29.0.14206865"
ndkVersion = "30.0.16248370"
}
androidComponents {
onVariants(selector().withFlavor("distribution" to "googlePlay")) { variant ->
variant.sources.jniLibs?.addGeneratedSourceDirectory(
fullDownloadTask,
MergeGeodeFilesTask::outputDir
)
variant.sources.assets?.addGeneratedSourceDirectory(
downloadGeodeResourcesTask,
DownloadAndUnzipGeodeResourcesTask::outputDir
)
}
}
dependencies {
implementation (platform("androidx.compose:compose-bom:$composeBOM"))
implementation ("androidx.core:core-ktx:1.17.0")
implementation (platform("androidx.compose:compose-bom:2026.09.00"))
implementation ("androidx.core:core-ktx:1.19.1")
implementation ("androidx.compose.ui:ui")
implementation ("androidx.compose.material3:material3")
implementation ("androidx.compose.ui:ui-tooling-preview")
implementation ("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
implementation ("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
implementation ("androidx.activity:activity-compose:1.12.3")
implementation ("androidx.activity:activity-ktx:1.12.3")
implementation ("androidx.appcompat:appcompat:1.7.1")
implementation ("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0")
implementation ("androidx.lifecycle:lifecycle-viewmodel-compose:2.11.0")
implementation ("androidx.activity:activity-compose:1.13.0")
implementation ("androidx.activity:activity-ktx:1.13.0")
implementation ("androidx.appcompat:appcompat:1.8.0")
implementation ("androidx.documentfile:documentfile:1.1.0")
implementation ("com.squareup.okio:okio:3.16.4")
implementation ("com.squareup.okhttp3:okhttp:5.3.2")
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0")
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json-okio:1.10.0")
implementation ("org.jetbrains.kotlinx:kotlinx-datetime:0.7.1")
implementation ("com.mikepenz:multiplatform-markdown-renderer-android:0.39.1")
implementation ("com.mikepenz:multiplatform-markdown-renderer-m3:0.39.1")
implementation ("androidx.browser:browser:1.9.0")
implementation ("com.squareup.okio:okio:3.18.2")
implementation ("com.squareup.okhttp3:okhttp:5.5.0")
implementation ("com.squareup.okhttp3:okhttp-coroutines:5.5.0")
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json-okio:1.11.0")
implementation ("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
implementation ("com.mikepenz:multiplatform-markdown-renderer-android:0.46.0")
implementation ("com.mikepenz:multiplatform-markdown-renderer-m3:0.46.0")
implementation ("androidx.browser:browser:1.10.0")
debugImplementation ("androidx.compose.ui:ui-tooling")
coreLibraryDesugaring ("com.android.tools:desugar_jdk_libs:2.1.5")
}
+9
View File
@@ -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>
+61 -6
View File
@@ -17,11 +17,8 @@
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.LIGHTS" />
@@ -90,6 +87,24 @@
<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"
@@ -110,7 +125,7 @@
</activity>
<activity
android:name=".GeometryDashActivity"
android:configChanges="orientation|screenSize|smallestScreenSize"
android:configChanges="fontScale|layoutDirection|density|smallestScreenSize|screenSize|uiMode|screenLayout|orientation|navigation|keyboardHidden|keyboard|locale"
android:exported="true"
android:hardwareAccelerated="true"
android:screenOrientation="landscape"
@@ -178,6 +193,26 @@
<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"
@@ -198,10 +233,30 @@
<data android:scheme="geode-launcher" android:host="main" />
</intent-filter>
</activity-alias>
<activity-alias
android:name=".MainActivityGreen"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_green"
android:label="@string/app_name"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="geode-launcher" android:host="main" />
</intent-filter>
</activity-alias>
<provider
android:name=".UserDirectoryProvider"
android:authorities="com.geode.launcher.user"
android:authorities="${applicationId}.user"
android:exported="true"
android:grantUriPermissions="true"
android:permission="android.permission.MANAGE_DOCUMENTS">
@@ -211,7 +266,7 @@
</provider>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="com.geode.launcher.fileprovider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
+7 -37
View File
@@ -1,47 +1,17 @@
cmake_minimum_required(VERSION 3.18.1)
cmake_minimum_required(VERSION 3.21)
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)
set_target_properties(launcherfix PROPERTIES CXX_EXTENSIONS OFF)
include(FetchContent)
option(USE_TULIPHOOK "enables choice of hooking library" OFF)
if (USE_TULIPHOOK)
FetchContent_Declare(TulipHook
GIT_REPOSITORY https://github.com/geode-sdk/TulipHook.git
GIT_TAG v3.1.4
set_target_properties(launcherfix PROPERTIES
INTERPROCEDURAL_OPTIMIZATION ON
UNITY_BUILD ON
C_VISIBILITY_PRESET hidden
CXX_VISIBILITY_PRESET hidden
)
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
# this is the last version that builds on android at all
GIT_REPOSITORY https://github.com/jmpews/Dobby
GIT_TAG 0932d69c320e786672361ab53825ba8f4245e9d3
)
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)
+42
View File
@@ -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);
+30 -300
View File
@@ -1,36 +1,13 @@
#include <dlfcn.h>
#include <jni.h>
#include <fstream>
#include <string>
#include <vector>
#include <cstdint>
#include <android/log.h>
#include <link.h>
#ifndef DISABLE_LAUNCHER_FIX
#include "base.h"
#include "log.hpp"
#ifdef USE_TULIPHOOK
#include <tulip/TulipHook.hpp>
#else
#include <dobby.h>
#endif
std::string DataPaths::original_data_path{};
std::string DataPaths::data_path{};
#endif
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() {}
};
bool DataPaths::patch_exceptions{};
extern "C"
JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setDataPath(
@@ -41,25 +18,11 @@ JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setDataPath(
auto is_copy = jboolean();
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);
}
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);
}
extern "C"
JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setOriginalDataPath(
JNIEnv *env,
@@ -69,227 +32,52 @@ JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setOriginalDataPath(
auto is_copy = jboolean();
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);
}
// i copied this function from the android linker
std::uint32_t elfhash(const char *_name) {
const unsigned char *name = reinterpret_cast<const unsigned char*>(_name);
std::uint32_t h = 0, g;
while (*name) {
h = (h << 4) + *name++;
g = h & 0xf0000000;
h ^= g;
h ^= g >> 24;
}
return h;
}
std::array<std::uint8_t, 3> SYMBOL_PATCH_VALUE{":3"};
bool patch_symbol(std::uint32_t* hash_table, char* str_table, ElfW(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
#ifdef USE_TULIPHOOK
auto res = tulip::hook::writeMemory(sym_name, SYMBOL_PATCH_VALUE.data(), SYMBOL_PATCH_VALUE.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), SYMBOL_PATCH_VALUE.data(), SYMBOL_PATCH_VALUE.size());
#endif
return true;
}
}
}
__android_log_print(ANDROID_LOG_WARN, "GeodeLauncher-fix", "could not find symbol %s to patch (hash: %u)", orig_name, hash);
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"
// 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"
};
}
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 (info->dlpi_name != nullptr && 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<ElfW(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;
std::uintptr_t soname_idx = 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_SONAME:
soname_idx = 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_ERROR, "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<ElfW(Sym)*>(sym_table_addr);
auto so_name = str_table + soname_idx;
if (strcmp(so_name, "libcocos2dcpp.so") != 0) {
__android_log_print(ANDROID_LOG_WARN, "GeodeLauncher-fix", "DT_SONAME gives us %s! that's not good", so_name);
}
auto has_error = false;
auto symbols_listing = get_symbols_listing();
for (const auto& symbol : symbols_listing) {
if (!patch_symbol(hash_table, str_table, sym_table, symbol.c_str())) {
has_error = true;
}
}
if (has_error) {
__android_log_print(
ANDROID_LOG_INFO,
"GeodeLauncher-fix",
"symbol patch diagnostics\nlibrary path: %s\naddrs: base=%p sym=%p str=%p hash=%p",
info->dlpi_name, reinterpret_cast<void*>(info->dlpi_addr), sym_table, str_table, hash_table
);
}
return 1;
}
return 0;
extern "C"
JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_enableExceptionsRenaming(JNIEnv*, jobject) {
DataPaths::patch_exceptions = true;
}
// this should be called after gd is loaded but before geode
extern "C"
JNIEXPORT int JNICALL Java_com_geode_launcher_LauncherFix_performExceptionsRenaming(JNIEnv*, jobject) {
return dl_iterate_phdr(on_dl_iterate, nullptr);
extern "C" JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_performPatches(JNIEnv*, jobject) {
dl_iterate_phdr(on_dl_iterate, nullptr);
}
std::optional<std::string> redirect_path(const char* pathname) {
auto& data_path = DataPaths::get_instance().data_path;
auto& original_data_path = DataPaths::get_instance().original_data_path;
std::string_view data_path = DataPaths::data_path;
std::string_view original_data_path = DataPaths::original_data_path;
if (!data_path.empty() && !original_data_path.empty()) {
// call this a c string optimization
if (std::strncmp(pathname, original_data_path.c_str(), original_data_path.size()) == 0) {
auto path = data_path + (pathname + original_data_path.size());
return path;
if (data_path.empty() || original_data_path.empty()) [[unlikely]] {
return std::nullopt;
}
// call this a c string optimization
if (std::strncmp(pathname, original_data_path.data(), original_data_path.size()) == 0) {
auto remaining_path = (pathname + original_data_path.size());
auto remaining_len = strlen(remaining_path);
std::string x;
x.reserve(data_path.size() + remaining_len);
x.append(data_path);
x.append(remaining_path, remaining_len);
return x;
}
return std::nullopt;
}
FILE* (*fopen_original)(const char *pathname, const char *mode);
FILE* fopen_hook(const char* pathname, const char* mode) {
auto path_str = redirect_path(pathname);
auto redirect = path_str.has_value() ? path_str->c_str() : pathname;
return fopen_original(redirect, 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) {
auto old_path_str = redirect_path(old_path);
auto old_redirect = old_path_str ? old_path_str->c_str() : old_path;
@@ -297,63 +85,5 @@ int rename_hook(const char* old_path, const char* 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_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
return rename(old_redirect, new_redirect);
}
+34
View File
@@ -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)...);
}
}
+303
View File
@@ -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,33 +2,106 @@ package com.customRobTop
import android.annotation.SuppressLint
import android.app.Activity
import android.content.*
import android.content.ActivityNotFoundException
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.Uri
import android.os.Build
import android.provider.DocumentsContract
import android.provider.OpenableColumns
import android.provider.Settings
import android.widget.Toast
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.openIMEKeyboard
import java.io.File
import java.io.FileInputStream
import java.io.IOException
import java.io.OutputStream
import java.lang.ref.WeakReference
import java.util.*
import java.util.UUID
@Suppress("unused", "UNUSED_PARAMETER")
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
var blockBackButton = false
private var keyboardActive = false
var curKeyboardInputFlags = 0
var isPaused = false
lateinit var me: WeakReference<Activity>
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)
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")
@@ -87,6 +160,18 @@ object BaseRobTopActivity {
}
}
@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
fun sendMail(subject: String, body: String, to: String) {
me.get()?.runOnUiThread {
@@ -264,4 +349,164 @@ object BaseRobTopActivity {
level = DeprecationLevel.HIDDEN
)
fun logEvent(event: String) {}
@JvmStatic
fun setKeyboardInputFlags(flags: Int) {
curKeyboardInputFlags = flags
}
@JvmStatic
fun pickImportFiles() {
try {
importFileActivityContract.launch(Unit)
} catch (_: ActivityNotFoundException) {
me.get()?.apply {
runOnUiThread {
Toast.makeText(
this,
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
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
}
}
@@ -145,7 +145,7 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
returnMessage: 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
if (error != null && !returnMessage.isNullOrEmpty()) {
@@ -171,23 +171,21 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
tryLoadLibrary(gdPackageInfo, Constants.FMOD_LIB_NAME)
tryLoadLibrary(gdPackageInfo, Constants.COCOS_LIB_NAME)
if (GamePackageUtils.getGameVersionCode(packageManager) >= 39L) {
/*
val customSymbols = PreferenceUtils.get(this).getBoolean(PreferenceUtils.Key.CUSTOM_SYMBOL_LIST)
if (customSymbols) {
val symbolFile = File(LaunchUtils.getBaseDirectory(this), "exception_symbols.txt")
LauncherFix.enableCustomSymbolList(symbolFile.path)
}
*/
val gameVersionCode = GamePackageUtils.getGameVersionCode(packageManager)
if (gameVersionCode >= 39L) {
// this fix requires geode v3, which is 2.206+
// there is a short period in which 2.206 users will still have geode v2, but whatever. ig
LauncherFix.performExceptionsRenaming()
LauncherFix.enableExceptionsRenaming()
}
LauncherFix.performPatches()
loadInternalMods()
val baseView = createView()
if (gameVersionCode >= 42L) {
mGLSurfaceView?.sendNewCocosEvents = true
}
if (!GeodeUtils.handleSafeArea && Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
val frameLayoutParams = ViewGroup.LayoutParams(
@@ -206,24 +204,55 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
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)
try {
val geodeFailed = try {
loadGeodeLibrary()
false
} catch (e: UnsatisfiedLinkError) {
handleGeodeException(e)
true
} catch (e: Exception) {
handleGeodeException(e)
true
}
setupNotificationView(baseView, geodeFailed)
}
private fun setupNotificationView(baseView: FrameLayout, geodeFailed: Boolean) {
val cards = determineDisplayedCards(this, geodeFailed)
if (cards.isNotEmpty()) {
val notificationView = ComposeView(this)
baseView.addView(notificationView)
notificationView.setContent {
LaunchNotification(cards)
}
}
}
@@ -364,8 +393,15 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
return "$sourceDir!/lib/$architecture/"
}
@SuppressLint("UnsafeDynamicallyLoadedCode")
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
// bundling the object with the application allows for nicer backtraces
try {
@@ -373,18 +409,29 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
System.loadLibrary("geode")
return
} catch (e: UnsatisfiedLinkError) {
if (!loadExternalGeode()) {
throw e
}
}
}
}
@SuppressLint("UnsafeDynamicallyLoadedCode")
private fun loadExternalGeode(): Boolean {
// but users may prefer it stored with data
val geodeFilename = LaunchUtils.geodeFilename
val geodePath = File(filesDir.path, "launcher/$geodeFilename")
if (geodePath.exists()) {
println("Loading Geode from ${geodePath.path}")
System.load(geodePath.path)
return
return true
}
// you know zmx i have 0 clue what this does so im
// just gonna like copy the binary from external
// also i get 20 million permission denied errors
val externalGeodePath = LaunchUtils.getInstalledGeodePath(this)!!
val externalGeodePath = LaunchUtils.getInstalledGeodePath(this)
?: return false
val copiedPath = File(filesDir.path, "copied")
if (copiedPath.exists()) {
@@ -401,14 +448,16 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
)
if (copiedGeodePath.exists()) {
// android 17 fix
copiedGeodePath.setWritable(false)
println("Loading Geode from ${externalGeodePath.name}")
System.load(copiedGeodePath.path)
return
return true
}
}
throw e
}
return false
}
private fun createView(): FrameLayout {
@@ -444,19 +493,6 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
this.mGLSurfaceView = glSurfaceView
frameLayout.addView(this.mGLSurfaceView)
val disableNotification = PreferenceUtils.get(this).getBoolean(PreferenceUtils.Key.DISABLE_REDESIGN)
if (!disableNotification) {
val hasCards = determineDisplayedCards(this)
if (hasCards.isNotEmpty()) {
val notificationView = ComposeView(this)
frameLayout.addView(notificationView)
notificationView.setContent {
LaunchNotification()
}
}
}
glSurfaceView.setEGLContextClientVersion(2)
glSurfaceView.setEGLConfigChooser(EGLConfigChooser())
@@ -475,6 +511,10 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
editText.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
glSurfaceView.cocos2dxEditText = editText
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
glSurfaceView.defaultFocusHighlightEnabled = false
}
return frameLayout
}
@@ -621,6 +661,10 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
mGLSurfaceView?.enableControllerEvents()
true
}
GeodeUtils.CAPABILITY_RESIZE_CALLBACK -> {
mGLSurfaceView?.supportsResizeEvents = true
true
}
else -> false
}
}
@@ -657,6 +701,56 @@ 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
@@ -13,7 +13,7 @@ object LauncherFix {
external fun setOriginalDataPath(dataPath: String)
external fun performExceptionsRenaming()
external fun enableExceptionsRenaming()
external fun enableCustomSymbolList(symbolsPath: String)
external fun performPatches()
}
@@ -7,6 +7,8 @@ import android.text.format.Formatter
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
@@ -70,6 +72,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -82,7 +85,9 @@ import com.geode.launcher.ui.theme.Theme
import com.geode.launcher.ui.theme.Theme.DARK
import com.geode.launcher.ui.theme.Theme.LIGHT
import com.geode.launcher.ui.theme.launcherTitleStyle
import com.geode.launcher.ui.theme.sapphireTitleStyle
import com.geode.launcher.updater.ReleaseManager
import com.geode.launcher.utils.ApplicationIcon
import com.geode.launcher.utils.Constants
import com.geode.launcher.utils.GamePackageUtils
import com.geode.launcher.utils.LaunchUtils
@@ -99,13 +104,25 @@ class MainActivity : ComponentActivity() {
val gdInstalled = GamePackageUtils.isGameInstalled(packageManager)
val geodeInstalled = LaunchUtils.isGeodeInstalled(this)
if (gdInstalled && geodeInstalled && intent.getBooleanExtra("restarted", false)) {
onLaunch(this)
val restartImmediately = intent.getBooleanExtra("restarted", false)
if (gdInstalled && geodeInstalled && restartImmediately) {
val withSafeMode = intent.getBooleanExtra("safe-mode", false)
onLaunch(this, withSafeMode)
}
enableEdgeToEdge()
clearDownloadedApks(this)
// remove old Geode for the like 4 people that downloaded the Play Store build
@Suppress("SENSELESS_COMPARISON")
if (BuildConfig.PREBUNDLED_GEODE != null) {
val preferences = PreferenceUtils.get(this)
if (!preferences.getBoolean(PreferenceUtils.Key.PREBUNDLED_GEODE_MIGRATION_PERFORMED)) {
LaunchUtils.getInstalledGeodePath(this)?.delete()
preferences.setBoolean(PreferenceUtils.Key.PREBUNDLED_GEODE_MIGRATION_PERFORMED, true)
}
}
val returnMessage = intent.extras?.getString(LaunchUtils.LAUNCHER_KEY_RETURN_MESSAGE)
val returnExtendedMessage = intent.extras?.getString(LaunchUtils.LAUNCHER_KEY_RETURN_EXTENDED_MESSAGE)
@@ -151,6 +168,8 @@ class MainActivity : ComponentActivity() {
launchViewModel.launchArguments = launchArguments
}
launchViewModel.beginLaunchFlow()
CompositionLocalProvider(LocalTheme provides theme) {
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
Surface(
@@ -176,6 +195,7 @@ fun LaunchCancelledIcon(cancelReason: LaunchViewModel.LaunchCancelReason, modifi
LaunchViewModel.LaunchCancelReason.GAME_OUTDATED,
LaunchViewModel.LaunchCancelReason.GAME_MISSING -> Icon(painterResource(R.drawable.icon_error), contentDescription = null, modifier)
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED,
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED_AUTOMATIC,
LaunchViewModel.LaunchCancelReason.GEODE_NOT_FOUND -> Icon(painterResource(R.drawable.icon_warning), contentDescription = null, modifier)
else -> Icon(painterResource(R.drawable.icon_info), contentDescription = null, modifier)
}
@@ -187,6 +207,9 @@ fun mapCancelReasonToInfo(cancelReason: LaunchViewModel.LaunchCancelReason): Lau
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED -> LaunchStatusInfo(
title = stringResource(R.string.launcher_cancelled_crash)
)
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED_AUTOMATIC -> LaunchStatusInfo(
title = stringResource(R.string.launcher_cancelled_crash_instant)
)
LaunchViewModel.LaunchCancelReason.GEODE_NOT_FOUND -> LaunchStatusInfo(
title = stringResource(R.string.geode_download_title)
)
@@ -196,7 +219,7 @@ fun mapCancelReasonToInfo(cancelReason: LaunchViewModel.LaunchCancelReason): Lau
)
LaunchViewModel.LaunchCancelReason.GAME_OUTDATED -> LaunchStatusInfo(
title = stringResource(R.string.launcher_cancelled_error),
details = stringResource(R.string.launcher_cancelled_outdated)
details = stringResource(R.string.launcher_cancelled_outdated, GamePackageUtils.getSupportedUnifiedVersion())
)
LaunchViewModel.LaunchCancelReason.AUTOMATIC,
LaunchViewModel.LaunchCancelReason.MANUAL-> LaunchStatusInfo(
@@ -245,7 +268,7 @@ fun mapLaunchStatusToInfo(state: LaunchViewModel.LaunchUIState, inSafeMode: Bool
)
}
is LaunchViewModel.LaunchUIState.Cancelled -> {
return mapCancelReasonToInfo(state.reason)
mapCancelReasonToInfo(state.reason)
}
}
}
@@ -323,6 +346,7 @@ fun RetryButtonContents(reason: LaunchViewModel.LaunchCancelReason) {
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_cancelled_resume))
}
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED_AUTOMATIC,
LaunchViewModel.LaunchCancelReason.MANUAL,
LaunchViewModel.LaunchCancelReason.AUTOMATIC -> {
Icon(
@@ -445,36 +469,76 @@ fun ExtraOptions(onSettings: () -> Unit) {
}
}
val lightOuterColorGradient = arrayOf(
data class ColorPalette(
val outerColors: List<Pair<Float, Color>>,
val innerColors: List<Pair<Float, Color>>,
)
data class AnimatedIconSpec(
val lightPalette: ColorPalette,
val darkPalette: ColorPalette,
@param:DrawableRes val innerId: Int,
@param:DrawableRes val outerId: Int,
)
data class BrandPalette(
@param:DrawableRes val darkLogo: Int,
@param:DrawableRes val lightLogo: Int,
@param:StringRes val title: Int,
val titleFont: TextStyle,
val animatedIcon: AnimatedIconSpec? = null,
)
val geodeColorPalette = BrandPalette(
animatedIcon = AnimatedIconSpec(
lightPalette = ColorPalette(
outerColors = listOf(
0.0f to Color(0xfff5b11d),
0.10f to Color(0xfff0834d),
0.24f to Color(0xffe85f6b),
0.49f to Color(0xffc9659e),
1.0f to Color(0xffb9588f)
)
val lightInnerColorGradient = arrayOf(
),
innerColors = listOf(
0.48f to Color(0xfff2ce00),
0.63f to Color(0xfff4b97e),
0.73f to Color(0xfff7b66a),
1.0f to Color(0xffeb8fac)
)
val darkOuterColorGradient = arrayOf(
),
darkPalette = ColorPalette(
outerColors = listOf(
0.0f to Color(0xfffffdff),
0.08f to Color(0xffF2EAF5),
0.12f to Color(0xffEDE5EF),
0.31f to Color(0xffCDB5CD),
0.49f to Color(0xffBA9BBC),
1.0f to Color(0xff8D7ACF),
)
val darkInnerColorGradient = arrayOf(
),
innerColors = listOf(
0.48f to Color(0xffffffff),
0.63f to Color(0xfff5e4c2),
0.73f to Color(0xffe5c7ad),
1.0f to Color(0xffb790a9),
)
),
innerId = R.drawable.geode_monochrome_inner,
outerId = R.drawable.geode_monochrome_outer,
),
darkLogo = R.drawable.geode_base,
lightLogo = R.drawable.geode_base_light,
title = R.string.launcher_title,
titleFont = launcherTitleStyle
)
val sapphireColorPalette = BrandPalette(
darkLogo = R.drawable.sapphire_full,
lightLogo = R.drawable.sapphire_full,
title = R.string.application_icon_sapphire,
titleFont = sapphireTitleStyle
)
@Preview(name = "non-animated, light")
@Preview(name = "non-animated, dark", uiMode = UI_MODE_NIGHT_YES)
@@ -498,22 +562,49 @@ fun GeodeLogoAnimatedPreview() {
CompositionLocalProvider(LocalTheme provides theme) {
GeodeLauncherTheme(theme = theme) {
Surface {
GeodeLogo(true)
GeodeLogo(shouldAnimate = true)
}
}
}
}
@Preview(name = "sapphire, non-animated, light")
@Preview(name = "sapphire, non-animated, dark", uiMode = UI_MODE_NIGHT_YES)
@Composable
fun SapphireLogoPreview() {
val theme = if (isSystemInDarkTheme()) DARK else LIGHT
CompositionLocalProvider(LocalTheme provides theme) {
GeodeLauncherTheme(theme = theme) {
Surface {
GeodeLogo(shouldAnimate = false, basePalette = sapphireColorPalette)
}
}
}
}
@Preview(name = "sapphire, animated, light")
@Preview(name = "sapphire, animated, dark", uiMode = UI_MODE_NIGHT_YES)
@Composable
fun SapphireLogoAnimatedPreview() {
val theme = if (isSystemInDarkTheme()) DARK else LIGHT
CompositionLocalProvider(LocalTheme provides theme) {
GeodeLauncherTheme(theme = theme) {
Surface {
GeodeLogo(shouldAnimate = true, basePalette = sapphireColorPalette)
}
}
}
}
@Composable
fun AnimatedLogo(modifier: Modifier = Modifier) {
fun AnimatedLogo(modifier: Modifier = Modifier, spec: AnimatedIconSpec) {
val theme = LocalTheme.current
val innerColorPalette = remember(theme) {
if (theme == LIGHT) lightInnerColorGradient else darkInnerColorGradient
}
val outerColorPalette = remember(theme) {
if (theme == LIGHT) lightOuterColorGradient else darkOuterColorGradient
}
val colorPalette = if (theme == LIGHT) spec.lightPalette
else spec.darkPalette
val innerColorPalette = colorPalette.innerColors
val outerColorPalette = colorPalette.outerColors
val infiniteTransition = rememberInfiniteTransition(label = "infinite transition")
val offset by infiniteTransition.animateFloat(
@@ -527,14 +618,14 @@ fun AnimatedLogo(modifier: Modifier = Modifier) {
)
val innerBrush = Brush.linearGradient(
colorStops = innerColorPalette,
colorStops = innerColorPalette.toTypedArray(),
start = Offset(0.0f, 0.0f + (165.06f * offset)),
end = Offset(0.0f, 165.06f + (165.06f * offset)),
tileMode = TileMode.Mirror
)
val outerBrush = Brush.linearGradient(
colorStops = outerColorPalette,
colorStops = outerColorPalette.toTypedArray(),
start = Offset(0.0f + (84.69f * offset), 0.0f + (165.06f * offset)),
end = Offset(84.69f + (84.69f * offset), 169.61f + (165.06f * offset)),
tileMode = TileMode.Mirror
@@ -542,7 +633,7 @@ fun AnimatedLogo(modifier: Modifier = Modifier) {
Box(modifier = modifier) {
Icon(
painter = painterResource(id = R.drawable.geode_monochrome_inner),
painter = painterResource(spec.innerId),
contentDescription = null,
modifier = Modifier
.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
@@ -553,9 +644,10 @@ fun AnimatedLogo(modifier: Modifier = Modifier) {
blendMode = BlendMode.SrcIn
)
}
.size(64.dp, 64.dp)
)
Icon(
painter = painterResource(id = R.drawable.geode_monochrome_outer),
painter = painterResource(spec.outerId),
contentDescription = null,
modifier = Modifier
.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
@@ -566,39 +658,50 @@ fun AnimatedLogo(modifier: Modifier = Modifier) {
blendMode = BlendMode.SrcIn
)
}
.size(64.dp, 64.dp)
)
}
}
@Composable
fun GeodeLogo(shouldAnimate: Boolean = false, modifier: Modifier = Modifier) {
Row(
fun GeodeLogo(modifier: Modifier = Modifier, shouldAnimate: Boolean = false, basePalette: BrandPalette = geodeColorPalette) {
FlowRow(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
itemVerticalAlignment = Alignment.CenterVertically,
modifier = modifier
) {
val theme = LocalTheme.current
if (basePalette.animatedIcon != null) {
Crossfade(
targetState = shouldAnimate,
animationSpec = tween(durationMillis = 1000, easing = FastOutSlowInEasing),
label="logo fade"
) { screen ->
when (screen) {
true -> AnimatedLogo(modifier = Modifier.size(64.dp, 64.dp))
true -> AnimatedLogo(modifier = Modifier.size(64.dp, 64.dp), spec = basePalette.animatedIcon)
false -> Image(
painterResource(if (theme == LIGHT)
R.drawable.geode_base_light else R.drawable.geode_base
basePalette.lightLogo else basePalette.darkLogo
),
contentDescription = null,
modifier = Modifier.size(64.dp, 64.dp)
)
}
}
} else {
Image(
painterResource(if (theme == LIGHT)
basePalette.lightLogo else basePalette.darkLogo
),
contentDescription = null,
modifier = Modifier.size(84.dp, 84.dp)
)
}
Text(
stringResource(R.string.launcher_title),
style = launcherTitleStyle,
stringResource(basePalette.title),
style = basePalette.titleFont,
fontSize = 64.sp,
modifier = Modifier
.padding(12.dp)
@@ -625,6 +728,9 @@ fun GeodeUpdateIndicator(snackbarHostState: SnackbarHostState, onRetry: () -> Un
ReleaseManager.UpdateException.Reason.EXTERNAL_FILE_IN_USE -> stringResource(
R.string.release_fetch_external_in_use_short
)
ReleaseManager.UpdateException.Reason.HASH_VALIDATION_FAILED -> stringResource(
R.string.release_fetch_hash_validation_failed_short
)
else -> stringResource(R.string.release_fetch_generic_short)
}
}
@@ -659,10 +765,6 @@ fun AltMainScreen(
) {
val context = LocalContext.current
LaunchedEffect(Unit) {
launchViewModel.beginLaunchFlow()
}
val launchUIState by launchViewModel.uiState.collectAsState()
val coroutineScope = rememberCoroutineScope()
var launchInSafeMode by remember {
@@ -690,7 +792,14 @@ fun AltMainScreen(
.padding(8.dp)
.verticalScroll(rememberScrollState())
) {
GeodeLogo(shouldAnimate = launchUIState.isInProgress())
val sapphireLogo = remember {
PreferenceUtils.get(context)
.getString(PreferenceUtils.Key.SELECTED_ICON) == ApplicationIcon.SAPPHIRE.toId()
}
val basePalette = if (sapphireLogo) sapphireColorPalette
else geodeColorPalette
GeodeLogo(shouldAnimate = launchUIState.isInProgress(), basePalette = basePalette)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -731,10 +840,11 @@ fun AltMainScreen(
)
// only show launcher update in a case where a user won't see it ingame
if (launchUIState is LaunchViewModel.LaunchUIState.Cancelled) {
val currentUIState = launchUIState
if (currentUIState is LaunchViewModel.LaunchUIState.Cancelled) {
val gameVersion = remember { GamePackageUtils.getGameVersionCodeOrNull(context.packageManager) }
if (gameVersion != null && gameVersion < Constants.SUPPORTED_VERSION_CODE_MIN_WARNING) {
if (gameVersion != null && gameVersion < Constants.SUPPORTED_VERSION_CODE_MIN_WARNING && currentUIState.reason != LaunchViewModel.LaunchCancelReason.GAME_OUTDATED) {
UnsupportedVersionWarning()
}
@@ -10,7 +10,10 @@ import android.provider.DocumentsContract
import android.provider.DocumentsProvider
import android.webkit.MimeTypeMap
import androidx.annotation.RequiresApi
import com.geode.launcher.utils.ApplicationIcon
import com.geode.launcher.utils.IconUtils
import com.geode.launcher.utils.LaunchUtils
import com.geode.launcher.utils.PreferenceUtils
import java.io.File
private val DEFAULT_ROOT_PROJECTION: Array<String> = arrayOf(
@@ -60,8 +63,17 @@ class UserDirectoryProvider : DocumentsProvider() {
DocumentsContract.Root.FLAG_SUPPORTS_RECENTS or
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
@@ -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()
}
}
@@ -1,115 +1,89 @@
package com.geode.launcher.log
import android.app.Application
import androidx.compose.runtime.mutableStateListOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.geode.launcher.utils.LaunchUtils
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
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
data class CrashDump @OptIn(ExperimentalTime::class) constructor(
data class CrashDump(
val filename: String,
val lastModified: Instant
val lastModified: Instant,
val fullPath: String,
)
class CrashViewModel(private val application: Application) : ViewModel() {
class CrashViewModel(crashDirectory: File) : BaseDirectoryViewModel<CrashDump>(crashDirectory) {
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as Application
CrashViewModel(
application = application
)
}
}
}
private val _lineState = mutableStateListOf<CrashDump>()
val lineState = _lineState
private val _isLoading = MutableStateFlow(false)
val isLoading = _isLoading.asStateFlow()
private var failedToLoad = false
private var logJob: Job? = null
@OptIn(ExperimentalTime::class)
private fun getCrashList(): List<CrashDump> {
val crashDirectory = LaunchUtils.getCrashDirectory(application)
CrashViewModel(crashDirectory)
}
}
}
var hasIndicator = false
override fun getItemKey(item: CrashDump) = item.filename
override fun getFileList(): List<CrashDump> {
val crashDirectory = baseDirectory
if (!crashDirectory.exists()) {
return emptyList()
}
val children = crashDirectory.listFiles {
// ignore indicator files (including old file)
_, name -> name != LaunchUtils.CRASH_INDICATOR_NAME && name != "last-pid"
_, 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())
lastModified = Instant.fromEpochMilliseconds(it.lastModified()),
fullPath = it.path,
)
}
.sortedByDescending { it.lastModified }
}
fun clearCrashes() {
logJob?.cancel()
fun clearIndicator() {
viewModelScope.launch(Dispatchers.IO) {
val crashDirectory = LaunchUtils.getCrashDirectory(application)
if (crashDirectory.exists()) {
val files = crashDirectory.listFiles()
files?.forEach {
it.delete()
}
}
}
_lineState.clear()
}
private fun loadLogs() {
_isLoading.value = true
logJob?.cancel()
logJob = viewModelScope.launch(Dispatchers.IO) {
val lines = getCrashList()
_lineState.clear()
_lineState.addAll(lines)
_isLoading.value = false
logJob = null
LaunchUtils.CRASH_INDICATOR_NAMES.forEach {
val crashFile = File(baseDirectory, it)
if (crashFile.exists()) {
crashFile.delete()
}
}
fun removeCrash(filename: String) {
logJob?.cancel()
val crashPath = File(LaunchUtils.getCrashDirectory(application), filename)
if (crashPath.exists()) {
crashPath.delete()
loadLogs()
hasIndicator = false
}
}
init {
loadLogs()
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())
}
}
@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@@ -38,12 +39,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
@@ -56,11 +55,14 @@ import com.geode.launcher.preferences.ApplicationLogsActivity
import com.geode.launcher.BuildConfig
import com.geode.launcher.R
import com.geode.launcher.UserDirectoryProvider
import com.geode.launcher.preferences.GeodeLogsListingActivity
import com.geode.launcher.preferences.TextViewActivity
import com.geode.launcher.ui.theme.Typography
import com.geode.launcher.utils.GamePackageUtils
import com.geode.launcher.utils.LaunchUtils
import com.geode.launcher.utils.PreferenceUtils
import kotlinx.coroutines.launch
import java.io.File
data class LoadFailureInfo(
val title: LaunchUtils.LauncherError,
@@ -138,14 +140,28 @@ fun ErrorInfoTitle(failureReason: LaunchUtils.LauncherError) {
}
fun onShareCrash(context: Context) {
fun findSomethingForCrash(context: Context): File? {
val lastCrash = LaunchUtils.getLastCrash(context)
if (lastCrash != null) {
return lastCrash
}
val logsDirectory = LaunchUtils.getGeodeLogsDirectory(context)
if (!logsDirectory.exists()) {
return null
}
return logsDirectory.listFiles()?.maxByOrNull { it.lastModified() }
}
fun onShareCrash(context: Context) {
val lastCrash = findSomethingForCrash(context)
if (lastCrash == null) {
Toast.makeText(context, R.string.launcher_error_export_missing, Toast.LENGTH_SHORT).show()
return
}
val baseDirectory = LaunchUtils.getBaseDirectory(context)
val baseDirectory = LaunchUtils.getBaseDirectory(context, true)
val providerLocation = lastCrash.toRelativeString(baseDirectory)
val documentsPath = "${UserDirectoryProvider.ROOT}${providerLocation}"
@@ -164,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) {
val launchIntent = Intent(context, ApplicationLogsActivity::class.java)
context.startActivity(launchIntent)
@@ -173,6 +202,10 @@ fun onShowLogs(context: Context) {
fun ErrorInfoDescription(description: String, modifier: Modifier = Modifier) {
val context = LocalContext.current
val currentRelease = remember {
@Suppress("SENSELESS_COMPARISON")
if (BuildConfig.PREBUNDLED_GEODE != null)
"${BuildConfig.PREBUNDLED_GEODE}*"
else
PreferenceUtils.get(context).getString(PreferenceUtils.Key.CURRENT_VERSION_TAG)
}
@@ -180,6 +213,7 @@ fun ErrorInfoDescription(description: String, modifier: Modifier = Modifier) {
GamePackageUtils.getUnifiedVersionName(context.packageManager)
}
SelectionContainer {
Column(modifier = modifier) {
Text(
stringResource(R.string.launcher_error_details),
@@ -209,6 +243,7 @@ fun ErrorInfoDescription(description: String, modifier: Modifier = Modifier) {
)
}
}
}
@Composable
@OptIn(ExperimentalLayoutApi::class)
@@ -254,6 +289,10 @@ fun ErrorInfoActions(extraDetails: String?, modifier: Modifier = Modifier) {
Text(stringResource(R.string.launcher_error_copy))
}
} else {
val crashFile = remember { findSomethingForCrash(context) }
val readableCrash = remember { crashFile?.extension == "log" }
if (crashFile != null) {
Button(onClick = { onShareCrash(context) }) {
Icon(
painterResource(R.drawable.icon_share),
@@ -263,11 +302,21 @@ fun ErrorInfoActions(extraDetails: String?, modifier: Modifier = Modifier) {
Text(stringResource(R.string.launcher_error_export_crash))
}
val developerSettingsEnabled = remember {
PreferenceUtils.get(context).getBoolean(PreferenceUtils.Key.DEVELOPER_MODE)
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))
}
if (developerSettingsEnabled) {
FilledTonalButton(onClick = { onShowLogs(context) }) {
}
} else {
Button(onClick = {
val launchIntent = Intent(context, GeodeLogsListingActivity::class.java)
context.startActivity(launchIntent)
}) {
Icon(
painterResource(R.drawable.icon_description),
contentDescription = null
@@ -36,6 +36,7 @@ import com.geode.launcher.utils.GeodeUtils
import com.geode.launcher.utils.PreferenceUtils
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlin.time.Duration.Companion.milliseconds
@Composable
@@ -142,7 +143,7 @@ fun onSettings(context: Context) {
}
@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
var isLongPress by remember { mutableStateOf(false) }
@@ -154,11 +155,11 @@ fun LongPressButton(onClick: () -> Unit, onLongPress: () -> Unit, enabled: Boole
interactionSource.interactions.collectLatest { interaction ->
when (interaction) {
is PressInteraction.Press -> {
delay(viewConfiguration.longPressTimeoutMillis)
delay(viewConfiguration.longPressTimeoutMillis.milliseconds)
// perform a second delay to make the action more obvious
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
delay(viewConfiguration.longPressTimeoutMillis)
delay(viewConfiguration.longPressTimeoutMillis.milliseconds)
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
@@ -186,6 +187,8 @@ fun LongPressButton(onClick: () -> Unit, onLongPress: () -> Unit, enabled: Boole
@Composable
fun UnsupportedVersionWarning(modifier: Modifier = Modifier) {
val gameVersion = GamePackageUtils.getSupportedUnifiedVersion()
InlineDialog(
headline = {
Icon(
@@ -201,7 +204,7 @@ fun UnsupportedVersionWarning(modifier: Modifier = Modifier) {
},
body = {
Text(
stringResource(R.string.launcher_game_update_required),
stringResource(R.string.launcher_game_update_required, gameVersion),
)
},
modifier = modifier.padding(8.dp)
@@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.displayCutoutPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
@@ -27,7 +26,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.unit.dp
@@ -41,12 +39,14 @@ import com.geode.launcher.utils.GamePackageUtils
import com.geode.launcher.utils.GeodeUtils
import com.geode.launcher.utils.PreferenceUtils
import kotlinx.coroutines.delay
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
enum class LaunchNotificationType {
GEODE_UPDATED, LAUNCHER_UPDATE_AVAILABLE, UPDATE_FAILED, UNSUPPORTED_VERSION;
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>()
if (GamePackageUtils.getGameVersionCode(context.packageManager) < Constants.SUPPORTED_VERSION_CODE_MIN_WARNING) {
@@ -54,10 +54,8 @@ fun determineDisplayedCards(context: Context): List<LaunchNotificationType> {
}
val releaseManager = ReleaseManager.get(context)
val loadAutomatically = PreferenceUtils.get(context).getBoolean(PreferenceUtils.Key.LOAD_AUTOMATICALLY)
val availableUpdate = releaseManager.availableLauncherUpdate.value
if (loadAutomatically && availableUpdate != null) {
val availableUpdate = releaseManager.availableLauncherUpdateTag.value
if (availableUpdate != null && !releaseManager.dismissedLauncherUpdate) {
cards.add(LaunchNotificationType.LAUNCHER_UPDATE_AVAILABLE)
}
@@ -71,6 +69,10 @@ fun determineDisplayedCards(context: Context): List<LaunchNotificationType> {
cards.add(LaunchNotificationType.UPDATE_FAILED)
}
if (geodeFailed) {
cards.add(LaunchNotificationType.FAILED_LOAD)
}
return cards
}
@@ -87,7 +89,7 @@ fun NotificationCardFromType(type: LaunchNotificationType) {
}
AnimatedNotificationCard(
displayLength = 5000L,
displayLength = 5.seconds,
onClick = {
showInfoDialog = true
}
@@ -105,21 +107,22 @@ fun NotificationCardFromType(type: LaunchNotificationType) {
OutdatedVersionContent()
}
}
LaunchNotificationType.FAILED_LOAD -> {
AnimatedNotificationCard {
ForwardCompatibilityContent()
}
}
else -> {}
}
}
@Composable
fun LaunchNotification() {
val context = LocalContext.current
fun LaunchNotification(cards: List<LaunchNotificationType>) {
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
val theme = Theme.fromInt(themeOption)
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
val cards = determineDisplayedCards(context)
CompositionLocalProvider(LocalTheme provides theme) {
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption) {
// surface is not in use, so this is unfortunately not provided
@@ -139,15 +142,15 @@ fun LaunchNotification() {
}
@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 {
MutableTransitionState(false).apply {
targetState = visibilityDelay <= 0
targetState = visibilityDelay <= Duration.ZERO
}
}
LaunchedEffect(true) {
if (visibilityDelay > 0) {
if (visibilityDelay > Duration.ZERO) {
delay(visibilityDelay)
state.targetState = true
}
@@ -207,6 +210,7 @@ fun LauncherUpdateContent(modifier: Modifier = Modifier) {
)
}
/*
@Composable
fun UpdateNotificationContent(modifier: Modifier = Modifier) {
ListItem(
@@ -223,6 +227,7 @@ fun UpdateNotificationContent(modifier: Modifier = Modifier) {
modifier = modifier.width(IntrinsicSize.Max)
)
}
*/
@Composable
fun OutdatedVersionContent(modifier: Modifier = Modifier) {
@@ -258,3 +263,22 @@ fun UpdateFailedContent(modifier: Modifier = Modifier) {
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.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.geode.launcher.BuildConfig
import com.geode.launcher.updater.ReleaseManager
import com.geode.launcher.utils.Constants
import com.geode.launcher.utils.GamePackageUtils
@@ -65,17 +66,25 @@ class LaunchViewModel(private val application: Application): ViewModel() {
}
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) {
MANUAL,
AUTOMATIC,
LAST_LAUNCH_CRASHED,
LAST_LAUNCH_CRASHED_AUTOMATIC,
GEODE_NOT_FOUND -> true
GAME_MISSING,
GAME_OUTDATED -> false
}
fun isCrash() = when (this) {
LAST_LAUNCH_CRASHED,
LAST_LAUNCH_CRASHED_AUTOMATIC -> true
else -> false
}
fun isGameInstallIssue() = when (this) {
GAME_OUTDATED,
GAME_MISSING -> true
@@ -100,23 +109,10 @@ class LaunchViewModel(private val application: Application): ViewModel() {
}
}
private val _uiState: MutableStateFlow<LaunchUIState>
init {
val preferenceUtils = PreferenceUtils.get(application)
val initialCancel = !preferenceUtils.getBoolean(PreferenceUtils.Key.UPDATE_AUTOMATICALLY)
&& !preferenceUtils.getBoolean(PreferenceUtils.Key.LOAD_AUTOMATICALLY)
// fixes a single frame of progress being displayed when we're not actually going to do work
_uiState = MutableStateFlow<LaunchUIState>(
if (initialCancel) LaunchUIState.Cancelled(LaunchCancelReason.AUTOMATIC)
else LaunchUIState.Initial
)
}
private val _uiState = MutableStateFlow<LaunchUIState>(LaunchUIState.Initial)
val uiState = _uiState.asStateFlow()
val nextLauncherUpdate = ReleaseManager.get(application).availableLauncherUpdate
val nextLauncherUpdate = ReleaseManager.get(application).availableLauncherUpdateTag
var loadFailure: LoadFailureInfo? = null
var launchArguments: LaunchArguments? = null
@@ -153,7 +149,7 @@ class LaunchViewModel(private val application: Application): ViewModel() {
fun currentCrashInfo(): LoadFailureInfo? {
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
}
@@ -162,12 +158,12 @@ class LaunchViewModel(private val application: Application): ViewModel() {
fun clearCrashInfo() {
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
}
}
private suspend fun preReadyCheck() {
private fun preReadyCheck() {
if (isCancelling) {
// don't let the game start if cancelled
return
@@ -176,7 +172,8 @@ class LaunchViewModel(private val application: Application): ViewModel() {
val packageManager = application.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) {
@@ -186,21 +183,32 @@ class LaunchViewModel(private val application: Application): ViewModel() {
return
}
if (loadFailure != null) {
// last launch crashed, so cancel
return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.LAST_LAUNCH_CRASHED))
}
if (GamePackageUtils.getGameVersionCode(packageManager) < Constants.SUPPORTED_VERSION_CODE_MIN) {
return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_OUTDATED))
// play store build only comes with the one build of geode
val minVersionCode = if (BuildConfig.GOOGLE_PLAY_BUILD) Constants.SUPPORTED_VERSION_CODE else Constants.SUPPORTED_VERSION_CODE_MIN
if (GamePackageUtils.getGameVersionCode(packageManager) < minVersionCode) {
_uiState.tryEmit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_OUTDATED))
return
}
if (!LaunchUtils.isGeodeInstalled(application)) {
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
}
@@ -212,13 +220,14 @@ class LaunchViewModel(private val application: Application): ViewModel() {
}
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) {
hasManuallyStarted = true
}
@@ -234,23 +243,39 @@ class LaunchViewModel(private val application: Application): ViewModel() {
isCancelling = false
if (!GamePackageUtils.isGameInstalled(application.packageManager)) {
return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_MISSING))
_uiState.tryEmit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_MISSING))
return
}
if (readyTimer == null) {
initReadyTimer()
}
if (!BuildConfig.GOOGLE_PLAY_BUILD) {
val releaseManager = ReleaseManager.get(application)
val hasGeode = LaunchUtils.isGeodeInstalled(application)
val shouldUpdate = PreferenceUtils.get(application).getBoolean(PreferenceUtils.Key.UPDATE_AUTOMATICALLY)
if ((shouldUpdate && !hasCheckedForUpdates) || !hasGeode) {
ReleaseManager.get(application).checkForUpdates(false)
releaseManager.checkForUpdates(false)
} else {
releaseManager.afterUseCachedUpdate()
}
if (isRestart) {
releaseManager.dismissedLauncherUpdate = true
}
}
preReadyCheck()
}
fun retryUpdate() {
if (BuildConfig.GOOGLE_PLAY_BUILD) {
return
}
ReleaseManager.get(application).checkForUpdates(true)
if (ReleaseManager.get(application).isInUpdate) {
restoreUpdateState = _uiState.value
@@ -261,7 +286,7 @@ class LaunchViewModel(private val application: Application): ViewModel() {
}
}
suspend fun cancelLaunch(isAutomatic: Boolean = false) {
fun cancelLaunch(isAutomatic: Boolean = false) {
// no need to double cancel
if (_uiState.value is LaunchUIState.Cancelled) {
return
@@ -273,11 +298,14 @@ class LaunchViewModel(private val application: Application): ViewModel() {
val releaseManager = ReleaseManager.get(application)
if (releaseManager.isInUpdate) {
_uiState.emit(LaunchUIState.Cancelled(reason, true))
_uiState.tryEmit(LaunchUIState.Cancelled(reason, true))
viewModelScope.launch {
releaseManager.cancelUpdate()
}
}
_uiState.emit(LaunchUIState.Cancelled(reason, false))
_uiState.tryEmit(LaunchUIState.Cancelled(reason, false))
}
override fun onCleared() {
@@ -4,22 +4,20 @@ import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.os.Build
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -29,7 +27,6 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
@@ -51,10 +48,9 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.geode.launcher.InstallReceiver
import com.geode.launcher.R
import com.geode.launcher.main.LauncherUpdater.downloadUpdate
import com.geode.launcher.ui.theme.Typography
import com.geode.launcher.updater.ReleaseManager
import com.geode.launcher.utils.LaunchUtils
@@ -63,9 +59,12 @@ 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
@@ -101,6 +100,7 @@ fun clearDownloadedApks(context: Context) {
object LauncherUpdater {
enum class LauncherUpdateState {
Began,
Downloading,
Active,
Inactive,
Finished;
@@ -113,12 +113,16 @@ object LauncherUpdater {
_uiState.emit(LauncherUpdateState.Inactive)
}
suspend fun installPackage(context: Context, file: File) {
if (!file.exists()) {
_uiState.emit(LauncherUpdateState.Finished)
return
suspend fun downloadUpdate(context: Context): Result<File> {
_uiState.emit(LauncherUpdateState.Downloading)
return withContext(Dispatchers.IO) {
ReleaseManager.get(context)
.downloadLatestLauncherUpdate()
}
}
suspend fun installPackage(context: Context, file: File) {
_uiState.emit(LauncherUpdateState.Began)
val installer = context.packageManager.packageInstaller
@@ -147,7 +151,7 @@ object LauncherUpdater {
}
})
withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { runInterruptible {
file.inputStream().use { apkStream ->
val length = file.length()
@@ -155,13 +159,8 @@ object LauncherUpdater {
PackageInstaller.SessionParams.MODE_FULL_INSTALL,
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
}
val sessionId = installer.createSession(params)
val session = installer.openSession(sessionId)
installer.openSession(sessionId).use { session ->
session.openWrite("package", 0, length).use { sessionStream ->
apkStream.copyTo(sessionStream)
session.fsync(sessionStream)
@@ -176,30 +175,33 @@ object LauncherUpdater {
)
session.commit(pi.intentSender)
session.close()
}
}
}}
}
}
suspend fun installLauncherUpdate(context: Context) {
val nextUpdate = ReleaseManager.get(context).availableLauncherUpdate.value
val launcherDownload = nextUpdate?.getDownload()
val launcherDownload = downloadUpdate(context)
.getOrElse { e ->
e.printStackTrace()
Toast.makeText(
context, context.getString(
R.string.launcher_update_download_failed,
e.cause
), Toast.LENGTH_SHORT
).show()
return
}
if (launcherDownload != null) {
val outputFile = launcherDownload.filename
val baseDirectory = context.filesDir
val outputPathFile = File(baseDirectory, outputFile)
if (!outputPathFile.exists()) {
if (!launcherDownload.exists()) {
return
}
PreferenceUtils.get(context).setBoolean(PreferenceUtils.Key.CLEANUP_APKS, true)
try {
LauncherUpdater.installPackage(context, outputPathFile)
LauncherUpdater.installPackage(context, launcherDownload)
} catch (e: Exception) {
e.printStackTrace()
val msg = context.getString(R.string.launcher_self_update_failed, e.message)
@@ -207,53 +209,44 @@ suspend fun installLauncherUpdate(context: Context) {
LauncherUpdater.resetState()
}
} else {
Toast.makeText(context, context.getString(R.string.release_fetch_no_releases), Toast.LENGTH_SHORT).show()
}
}
@Composable
fun LauncherUpdateDialog(onDismiss: () -> Unit) {
fun LauncherUpdateDialog(isCancelling: Boolean, onDismiss: () -> Unit) {
val updateState by LauncherUpdater.uiState.collectAsState()
val cancellable = updateState != LauncherUpdater.LauncherUpdateState.Active
LaunchedEffect(updateState) {
if (updateState == LauncherUpdater.LauncherUpdateState.Finished) {
onDismiss()
}
}
if (cancellable) {
return
}
Dialog(
onDismissRequest = {
if (cancellable) onDismiss()
},
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(18.dp),
modifier = Modifier.padding(24.dp)
) {
CircularProgressIndicator()
Text(
stringResource(R.string.launcher_self_update_progress),
style = MaterialTheme.typography.bodyLarge
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)
@@ -261,26 +254,51 @@ fun LauncherUpdateDialog(onDismiss: () -> Unit) {
fun LauncherUpdateInformation(onDismiss: () -> Unit) {
val context = LocalContext.current
val nextRelease = remember { ReleaseManager.get(context).availableLauncherUpdate.value }
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(onDismiss = {
LauncherUpdateDialog(cancellingUpdate, onDismiss = {
cancellingUpdate = true
coroutineScope.launch {
updateJob?.cancelAndJoin()
beginUpdate = false
}
})
}
if (nextRelease != null) {
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, nextRelease.release.tagName),
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())
@@ -295,9 +313,11 @@ fun LauncherUpdateInformation(onDismiss: () -> Unit) {
Row(modifier = Modifier.padding(vertical = 12.dp)) {
Button(
onClick = {
coroutineScope.launch {
installLauncherUpdate(context)
updateJob = coroutineScope.launch {
cancellingUpdate = false
beginUpdate = true
installLauncherUpdate(context)
}
},
) {
@@ -341,6 +361,16 @@ fun LauncherUpdateInformation(onDismiss: () -> Unit) {
)
}
}
} else {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp)
) {
CircularProgressIndicator()
}
}
Spacer(modifier = Modifier.size(4.dp))
}
@@ -362,14 +392,24 @@ fun LauncherUpdateIndicator(modifier: Modifier = Modifier) {
}
}
var updateJob by remember { mutableStateOf<Job?>(null) }
var cancellingUpdate by remember { mutableStateOf(false) }
var beginUpdate by remember { mutableStateOf(false) }
if (beginUpdate) {
LauncherUpdateDialog {
LauncherUpdateDialog(cancellingUpdate) {
cancellingUpdate = true
coroutineScope.launch {
updateJob?.cancelAndJoin()
updateJob = null
beginUpdate = false
}
}
}
ElevatedCard(modifier) {
// there's like a single pixel of the shadow that gets clipped without this...
ElevatedCard(modifier = modifier.padding(bottom = 1.dp)) {
Column(
// buttons add enough padding already, lower to compensate
modifier = Modifier.padding(
@@ -394,9 +434,11 @@ fun LauncherUpdateIndicator(modifier: Modifier = Modifier) {
TextButton(
onClick = {
coroutineScope.launch {
installLauncherUpdate(context)
updateJob = coroutineScope.launch {
cancellingUpdate = false
beginUpdate = true
installLauncherUpdate(context)
}
},
) {
@@ -129,7 +129,7 @@ fun onSelectIcon(context: Context, selectedIcon: ApplicationIconDetails) {
val iconData = IconUtils.getIconDetails(it)
packageManager.setComponentEnabledSetting(
ComponentName(context, "${context.packageName}.${iconData.component}"),
ComponentName(context, "${context.packageName.removeSuffix(".play")}.${iconData.component}"),
if (iconData.id == selectedIcon.id)
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
else PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
@@ -237,7 +237,6 @@ fun ApplicationIconScreen(
) {
ApplicationIcon.entries.forEach {
val iconData = IconUtils.getIconDetails(it)
IconCard(iconData, selectedIcon == iconData.id)
}
@@ -1,6 +1,5 @@
package com.geode.launcher.preferences
import android.content.ClipData
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
@@ -9,7 +8,6 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
@@ -18,6 +16,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -42,13 +41,8 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
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.stringResource
@@ -61,6 +55,8 @@ import com.geode.launcher.R
import com.geode.launcher.log.LogLine
import com.geode.launcher.log.LogPriority
import com.geode.launcher.log.LogViewModel
import com.geode.launcher.preferences.components.SelectDialog
import com.geode.launcher.preferences.components.SelectOption
import com.geode.launcher.ui.theme.GeodeLauncherTheme
import com.geode.launcher.ui.theme.LocalTheme
import com.geode.launcher.ui.theme.Theme
@@ -144,33 +140,10 @@ fun SelectLogLevelDialog(
@Composable
fun LogCard(logLine: LogLine, modifier: Modifier = Modifier) {
val haptics = LocalHapticFeedback.current
val clipboard = LocalClipboard.current
val resources = LocalResources.current
val coroutineScope = rememberCoroutineScope()
SelectionContainer {
Column(
modifier = modifier
.padding(8.dp)
.pointerInput(Unit) {
detectTapGestures(
onLongPress = {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
val clipboardLabel = resources.getString(R.string.application_log_copy_label)
val clipboardData = logLine.asSimpleString
coroutineScope.launch {
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText(
clipboardLabel, clipboardData
))
)
}
}
)
},
) {
Text(
"[${logLine.priority.toChar()}] ${logLine.formattedTime}",
@@ -186,6 +159,7 @@ fun LogCard(logLine: LogLine, modifier: Modifier = Modifier) {
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -12,46 +12,32 @@ 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.Spacer
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.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.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.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.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.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -60,16 +46,15 @@ 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.Typography
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.ExperimentalTime
import kotlin.time.toJavaInstant
class CrashLogsActivity : ComponentActivity() {
@@ -99,7 +84,7 @@ class CrashLogsActivity : ComponentActivity() {
}
fun shareCrash(context: Context, filename: String) {
val baseDirectory = LaunchUtils.getBaseDirectory(context)
val baseDirectory = LaunchUtils.getBaseDirectory(context, true)
val crashPath = File(LaunchUtils.getCrashDirectory(context), filename)
if (!crashPath.exists()) {
@@ -124,14 +109,22 @@ fun shareCrash(context: Context, filename: String) {
}
}
@OptIn(ExperimentalTime::class)
@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)
) {
@@ -169,7 +162,7 @@ fun CrashCard(crashDump: CrashDump, crashViewModel: CrashViewModel, modifier: Mo
}
IconButton(onClick = {
crashViewModel.removeCrash(crashDump.filename)
crashViewModel.removeFile(crashDump.filename)
}) {
Icon(
painterResource(R.drawable.icon_delete),
@@ -180,120 +173,59 @@ fun CrashCard(crashDump: CrashDump, crashViewModel: CrashViewModel, modifier: Mo
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CrashLogsScreen(
onBackPressedDispatcher: OnBackPressedDispatcher?,
crashViewModel: CrashViewModel = viewModel(factory = CrashViewModel.Factory)
) {
val logLines = crashViewModel.lineState
val isLoading by crashViewModel.isLoading.collectAsState()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
var showMoreOptions by remember { mutableStateOf(false) }
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
Column {
TopAppBar(
navigationIcon = {
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
Icon(
painterResource(R.drawable.icon_arrow_back),
contentDescription = stringResource(R.string.back_icon_alt)
)
}
},
actions = {
IconButton(onClick = { showMoreOptions = !showMoreOptions }) {
Icon(
painterResource(R.drawable.icon_more_vert),
contentDescription = stringResource(R.string.application_logs_more)
)
}
DropdownMenu(
expanded = showMoreOptions,
onDismissRequest = { showMoreOptions = false }
) {
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_delete),
painterResource(R.drawable.icon_remove_selection),
contentDescription = null
)
},
text = {
Text(stringResource(R.string.application_logs_delete))
Text(stringResource(R.string.application_crashes_clear_indicator))
}, onClick = {
crashViewModel.clearCrashes()
showMoreOptions = false
crashViewModel.clearIndicator()
onDismiss()
}
)
}
},
title = {
Text(
stringResource(R.string.title_activity_crash_logs),
maxLines = 1,
overflow = TextOverflow.Ellipsis
} else {
DropdownMenuItem(
leadingIcon = {
Icon(
painterResource(R.drawable.icon_skull),
contentDescription = null
)
},
scrollBehavior = scrollBehavior,
)
if (isLoading) {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth()
text = {
Text(stringResource(R.string.application_crashes_create_indicator))
}, onClick = {
crashViewModel.createIndicator()
onDismiss()
}
)
}
}
}
) { innerPadding ->
LazyColumn(
Modifier
.padding(innerPadding)
.fillMaxWidth(),
verticalArrangement = Arrangement.SpaceEvenly,
) {
if (!isLoading && logLines.isEmpty()) {
item {
Text(
stringResource(R.string.application_crashes_no_dumps),
textAlign = TextAlign.Center,
style = Typography.bodyLarge,
modifier = Modifier.fillMaxSize().padding(8.dp)
)
}
}
val itemsSize = logLines.size
itemsIndexed(
logLines,
key = { index, line -> line.filename }
) { 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
)
) { line, shape ->
CrashCard(
line,
crashViewModel,
modifier = Modifier.clip(shape)
)
}
item {
Spacer(Modifier.height(4.dp))
}
}
}
}
@Preview(showSystemUi = true)
@@ -40,7 +40,15 @@ import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.geode.launcher.BuildConfig
import com.geode.launcher.R
import com.geode.launcher.preferences.components.OptionsButton
import com.geode.launcher.preferences.components.OptionsGroup
import com.geode.launcher.preferences.components.ProfileCreateCard
import com.geode.launcher.preferences.components.ProfileSelectCard
import com.geode.launcher.preferences.components.SettingsCard
import com.geode.launcher.preferences.components.SettingsSelectCard
import com.geode.launcher.preferences.components.SettingsStringCard
import com.geode.launcher.ui.theme.GeodeLauncherTheme
import com.geode.launcher.ui.theme.LocalTheme
import com.geode.launcher.ui.theme.Theme
@@ -151,10 +159,14 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
title = stringResource(R.string.preference_launch_arguments_name),
dialogTitle = stringResource(R.string.preference_launch_arguments_set_title),
preferenceKey = PreferenceUtils.Key.LAUNCH_ARGUMENTS,
filterInput = { it.filter { c ->
filterInput = {
it.filter { c ->
// if only there was a better way to define this!
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890!@#$%^&*(){}<>[]?:;'\"~`-_+=\\| ".contains(c)
}}
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890!@#$%^&*(){}<>[]?:;'\"~`-_+=\\| ".contains(
c
)
}
}
)
/*
@@ -179,7 +191,7 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
title = stringResource(R.string.preferences_view_logs),
icon = {
Icon(
painterResource(R.drawable.icon_description),
painterResource(R.drawable.icon_mobile_code),
contentDescription = null
)
},
@@ -193,7 +205,10 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
)
}
if (!BuildConfig.GOOGLE_PLAY_BUILD) {
OptionsGroup(title = stringResource(R.string.preference_category_updater)) {
val context = LocalContext.current
SettingsSelectCard(
title = stringResource(R.string.preference_release_channel_tag_name),
dialogTitle = stringResource(R.string.preference_release_channel_select),
@@ -202,9 +217,19 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
0 to stringResource(R.string.preference_release_channel_stable),
1 to stringResource(R.string.preference_release_channel_beta),
2 to stringResource(R.string.preference_release_channel_nightly),
),
extraSelectBehavior = {
PreferenceUtils.get(context)
.setLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME, 0L)
}
)
SettingsCard(
title = stringResource(R.string.preference_disable_update_cache),
preferenceKey = PreferenceUtils.Key.DISABLE_UPDATE_CACHE
)
}
}
OptionsGroup(title = stringResource(R.string.preference_category_testing)) {
SettingsSelectCard(
@@ -213,9 +238,21 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
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),
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
),
)
)
@@ -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)
)
}
}
@@ -2,6 +2,7 @@ package com.geode.launcher.preferences
import android.app.UiModeManager
import android.content.ActivityNotFoundException
import android.content.ClipData
import android.content.Context
import android.content.Intent
import android.os.Build
@@ -19,10 +20,31 @@ import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
@@ -31,9 +53,12 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.painterResource
@@ -45,24 +70,39 @@ import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.viewmodel.compose.viewModel
import com.geode.launcher.BuildConfig
import com.geode.launcher.MainActivity
import com.geode.launcher.R
import com.geode.launcher.UserDirectoryProvider
import com.geode.launcher.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.LocalTheme
import com.geode.launcher.ui.theme.Theme
import com.geode.launcher.ui.theme.Typography
import com.geode.launcher.ui.theme.robotoMonoFamily
import com.geode.launcher.updater.ReleaseViewModel
import com.geode.launcher.utils.ApplicationIcon
import com.geode.launcher.utils.Constants
import com.geode.launcher.utils.GamePackageUtils
import com.geode.launcher.utils.IconUtils
import com.geode.launcher.utils.LaunchUtils
import com.geode.launcher.utils.PreferenceUtils
import kotlinx.coroutines.launch
import java.net.ConnectException
import java.net.UnknownHostException
import kotlin.math.roundToInt
import kotlin.time.Clock
class SettingsActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
@@ -119,6 +159,53 @@ 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
fun UpdateIndicator(
snackbarHostState: SnackbarHostState,
@@ -128,6 +215,17 @@ fun UpdateIndicator(
val resources = LocalResources.current
var enablePopup by remember { mutableStateOf(false) }
var enableHelp by remember { mutableStateOf(false) }
var currentError by remember { mutableStateOf<Exception?>(null) }
val currentErrorS = currentError
if (currentErrorS != null && enableHelp) {
UpdateErrorDialog(currentErrorS) {
enableHelp = false
}
}
when (updateStatus) {
is ReleaseViewModel.ReleaseUIState.InUpdateCheck -> {
@@ -165,7 +263,12 @@ fun UpdateIndicator(
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 -> {
if (updateStatus.hasUpdated) {
@@ -374,11 +477,42 @@ fun GameplaySettingsGroup() {
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)) {
@@ -412,7 +546,10 @@ fun UpdateSettingsGroup(releaseViewModel: ReleaseViewModel, snackbarHostState: S
contentDescription = null
)
},
description = stringResource(R.string.preference_check_for_updates_description, checkAgoString)
description = stringResource(
R.string.preference_check_for_updates_description,
checkAgoString
)
.takeIf { lastUpdateCheck != 0L }
)
},
@@ -430,7 +567,7 @@ fun UpdateSettingsGroup(releaseViewModel: ReleaseViewModel, snackbarHostState: S
}
@Composable
fun AdvancedSettingsGroup(developerModeEnabled: Boolean) {
fun AdvancedSettingsGroup(developerModeEnabled: Boolean, snackbarHostState: SnackbarHostState) {
val context = LocalContext.current
OptionsGroup(stringResource(R.string.preference_category_developer)) {
@@ -447,6 +584,20 @@ fun AdvancedSettingsGroup(developerModeEnabled: Boolean) {
)
}
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 = {
@@ -461,32 +612,7 @@ fun AdvancedSettingsGroup(developerModeEnabled: Boolean) {
}
)
var showSafeModeDialog by remember { mutableStateOf(false) }
OptionsButton(
title = stringResource(R.string.preference_launch_safe_mode),
icon = {
Icon(
painterResource(R.drawable.icon_shield),
contentDescription = null
)
}
) {
showSafeModeDialog = true
}
if (showSafeModeDialog) {
SafeModeDialog(onDismiss = {
showSafeModeDialog = false
}) {
val launchIntent = Intent(context, MainActivity::class.java).apply {
// hardcode it, why not
action = Intent.ACTION_VIEW
data = "geode-launcher://main/launch?safe-mode=true".toUri()
}
context.startActivity(launchIntent)
}
}
BackupButton(snackbarHostState)
}
}
@@ -502,14 +628,21 @@ fun AboutSettingsGroup(developerModeEnabled: Boolean) {
OptionsButton(
stringResource(R.string.preference_launcher_version_name),
stringResource(R.string.preference_launcher_version_description, BuildConfig.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)
Toast.makeText(
context,
R.string.preference_enable_developer_again,
Toast.LENGTH_SHORT
)
.show()
} else {
showDeveloperDialog = true
@@ -524,15 +657,22 @@ fun AboutSettingsGroup(developerModeEnabled: Boolean) {
showDeveloperDialog = false
},
onEnable = {
PreferenceUtils.get(context).setBoolean(PreferenceUtils.Key.DEVELOPER_MODE, true)
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, currentRelease ?: "unknown"),
stringResource(
R.string.preference_loader_version_description,
if (BuildConfig.PREBUNDLED_GEODE != null)
"${BuildConfig.PREBUNDLED_GEODE}*"
else (currentRelease ?: "unknown")
),
displayInline = true
)
@@ -546,22 +686,32 @@ fun AboutSettingsGroup(developerModeEnabled: Boolean) {
}
val gameSource = remember {
resources.getString(when (GamePackageUtils.identifyGameSource(context.packageManager)) {
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),
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),
stringResource(
R.string.preference_architecture_description,
Build.MODEL,
LaunchUtils.applicationArchitecture
),
displayInline = true
)
}
@@ -644,7 +794,7 @@ fun SettingsScreen(
val developerModeEnabled by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DEVELOPER_MODE)
AdvancedSettingsGroup(developerModeEnabled)
AdvancedSettingsGroup(developerModeEnabled, snackbarHostState)
AboutSettingsGroup(developerModeEnabled)
Spacer(Modifier.height(4.dp))
@@ -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)
}
}
@@ -1,4 +1,4 @@
package com.geode.launcher.preferences
package com.geode.launcher.preferences.components
import android.content.Context
import android.content.Intent
@@ -8,41 +8,28 @@ 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.ColumnScope
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.rememberScrollState
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
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.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateListOf
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.draw.clip
@@ -51,21 +38,17 @@ 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.text.style.TextAlign
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
import com.geode.launcher.utils.Profile
import com.geode.launcher.utils.ProfileManager
import kotlin.collections.get
import kotlin.math.log10
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.system.exitProcess
@@ -237,244 +220,6 @@ fun SettingsStringCard(
}
}
@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 SettingsFPSCard(
title: String,
@@ -570,120 +315,6 @@ fun SettingsRangeCard(
}
}
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 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 ProfileCreateCard() {
var showDialog by remember { mutableStateOf(false) }
@@ -713,7 +344,7 @@ fun ProfileSelectCard() {
profileManager.getCurrentProfile()
}
var currentProfileName = remember {
val currentProfileName = remember {
val savedProfile = currentProfileId
if (savedProfile == null) {
"Default"
@@ -803,90 +434,6 @@ fun ProfileSelectCard() {
}
}
@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()
}
}
}
}
}
@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()
}
}
}
@Composable
fun OptionsButton(title: String, description: String? = null, icon: (@Composable () -> Unit)? = null, displayInline: Boolean = false, onClick: () -> Unit) {
OptionsCard(
@@ -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()
}
}
}
@@ -16,9 +16,20 @@ 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
)
@@ -3,10 +3,9 @@ package com.geode.launcher.updater
import com.geode.launcher.utils.LaunchUtils
import kotlin.time.Instant
import kotlinx.serialization.Serializable
import kotlin.time.ExperimentalTime
@Serializable
data class Asset @OptIn(ExperimentalTime::class) constructor(
data class Asset(
val url: String,
val id: Int,
val name: String,
@@ -14,10 +13,11 @@ data class Asset @OptIn(ExperimentalTime::class) constructor(
val createdAt: Instant,
val updatedAt: Instant,
val browserDownloadUrl: String,
val digest: String?,
)
@Serializable
data class Release @OptIn(ExperimentalTime::class) constructor(
data class Release(
val url: String,
val id: Int,
val targetCommitish: String,
@@ -31,12 +31,29 @@ data class Release @OptIn(ExperimentalTime::class) constructor(
)
@Serializable
data class LoaderVersion @OptIn(ExperimentalTime::class) constructor(
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 prerelease: Boolean,
val downloads: LoaderDownload,
)
@Serializable
@@ -45,12 +62,46 @@ data class LoaderPayload<T>(
val error: String
)
data class DownloadableAsset(val url: String, val filename: String, val size: Long? = null)
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() {
@@ -67,7 +118,6 @@ class DownloadableGitHubLoaderRelease(private val release: Release) : Downloadab
return release.tagName
}
@OptIn(ExperimentalTime::class)
override fun getDescriptor(): Long {
return release.createdAt.epochSeconds
}
@@ -84,11 +134,15 @@ class DownloadableGitHubLoaderRelease(private val release: Release) : Downloadab
override fun getDownload(): DownloadableAsset? {
val download = getGitHubDownload() ?: return null
return DownloadableAsset(
url = download.browserDownloadUrl,
filename = download.name,
size = download.size.toLong()
)
return mapDownload(download)
}
override fun getResourcesDownload(): DownloadableAsset? {
val download = release.assets.find {
it.name == "resources.zip"
} ?: return null
return mapDownload(download)
}
}
@@ -97,7 +151,6 @@ class DownloadableLauncherRelease(val release: Release) : Downloadable() {
return release.tagName
}
@OptIn(ExperimentalTime::class)
override fun getDescriptor(): Long {
return release.createdAt.epochSeconds
}
@@ -115,18 +168,17 @@ class DownloadableLauncherRelease(val release: Release) : Downloadable() {
* 0 | 0 | 1
* surprise! it's an xnor
*/
(asset.name.contains("android32")) == use32BitPlatform
asset.name.endsWith("apk") && asset.name.contains("android32") == use32BitPlatform
}
}
override fun getDownload(): DownloadableAsset? {
val download = getGitHubDownload() ?: return null
return DownloadableAsset(
url = download.browserDownloadUrl,
filename = download.name,
size = download.size.toLong()
)
return mapDownload(download)
}
override fun getResourcesDownload(): DownloadableAsset? {
return null
}
}
@@ -135,16 +187,27 @@ class DownloadableLoaderRelease(private val version: LoaderVersion) : Downloadab
return version.tag
}
@OptIn(ExperimentalTime::class)
override fun getDescriptor(): Long {
return version.createdAt.epochSeconds
}
override fun getDownload(): DownloadableAsset? {
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 = "https://github.com/geode-sdk/geode/releases/download/${version.tag}/$filename",
filename = filename
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,16 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import okhttp3.Cache
import okhttp3.OkHttpClient
import okio.HashingSink
import okio.blackholeSink
import okio.buffer
import okio.sink
import okio.source
import java.io.File
import java.io.FileNotFoundException
import java.io.InterruptedIOException
import java.io.IOException
import kotlin.math.max
import kotlin.time.Clock
import kotlin.time.Duration.Companion.minutes
@@ -69,7 +75,8 @@ class ReleaseManager private constructor(
class UpdateException(reason: Reason? = null, cause: Throwable? = null) : Exception(reason?.name, cause) {
enum class Reason {
EXTERNAL_FILE_IN_USE
EXTERNAL_FILE_IN_USE,
HASH_VALIDATION_FAILED
}
var reason: Reason? = reason
@@ -84,10 +91,18 @@ class ReleaseManager private constructor(
val isInUpdate: Boolean
get() = _uiState.value !is ReleaseManagerState.Failure && _uiState.value !is ReleaseManagerState.Finished
private val _availableLauncherUpdate = MutableStateFlow<DownloadableLauncherRelease?>(null)
val availableLauncherUpdate = _availableLauncherUpdate.asStateFlow()
private val _availableLauncherUpdateTag = MutableStateFlow<String?>(null)
val availableLauncherUpdateTag = _availableLauncherUpdateTag.asStateFlow()
private val _availableLauncherUpdateDetails = MutableStateFlow<DownloadableLauncherRelease?>(null)
val availableLauncherUpdateDetails = _availableLauncherUpdateDetails.asStateFlow()
var dismissedLauncherUpdate = false
var skipStateUpdate = false
private fun sendError(e: Exception) {
skipStateUpdate = true
_uiState.value = ReleaseManagerState.Failure(e)
// ignore cancellation, it's good actually
@@ -133,35 +148,96 @@ class ReleaseManager private constructor(
}
}
private suspend fun downloadLauncherUpdate(release: Downloadable) {
val download = release.getDownload() ?: return
private suspend fun downloadLauncherUpdate(release: Downloadable): Result<File> {
val download = release.getDownload()
?: return Result.failure(FileNotFoundException())
val outputDirectory = applicationContext.filesDir
val outputFile = File(outputDirectory, download.filename)
if (outputFile.exists()) {
// only download the apk once
return
try {
val hash = DownloadUtils.downloadFile(
httpClient,
download.url,
outputFile
)
if (download.hash != null && download.hash != hash) {
Log.w("ReleaseManager", "downloadLauncherUpdate failed: found $hash but expected ${download.hash}")
outputFile.delete()
throw UpdateException(UpdateException.Reason.HASH_VALIDATION_FAILED)
}
} catch (e: Exception) {
return Result.failure(e)
}
_uiState.value = ReleaseManagerState.InDownload(0, download.size)
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 {
val fileStream = DownloadUtils.downloadStream(
httpClient,
download.url
) { progress, outOf ->
_uiState.value = ReleaseManagerState.InDownload(progress, outOf)
if (!outputDir.exists()) {
outputDir.mkdirs()
}
fileStream.copyTo(outputFile.outputStream())
val hash = DownloadUtils.downloadFile(
httpClient,
resourceAsset.url,
downloadFile,
onProgress = { progress, outOf ->
if (!skipStateUpdate)
_uiState.value = ReleaseManagerState.InDownload(initialSize + progress, outOf + initialSize)
},
)
if (resourceAsset.hash != null && resourceAsset.hash != hash) {
Log.w("ReleaseManager", "performResourceDownload failed: found $hash but expected ${resourceAsset.hash}")
throw UpdateException(UpdateException.Reason.HASH_VALIDATION_FAILED)
}
downloadFile.inputStream().use { zip ->
DownloadUtils.copyZipStreamToDirectory(
zip,
outputDir
)
}
if (finalDir.exists()) {
finalDir.deleteRecursively()
}
if (!outputDir.renameTo(finalDir)) {
println("Failed to rename temporary directory!")
DownloadUtils.copyDirectory(outputDir, finalDir)
}
} catch (e: Exception) {
sendError(e)
return
return false
} finally {
val tempPathClone = File(outputPath)
try {
if (downloadFile.exists()) downloadFile.delete()
if (tempPathClone.exists()) tempPathClone.deleteRecursively()
} catch (_: IOException) {}
}
return true
}
private suspend fun performUpdate(release: Downloadable) {
skipStateUpdate = false
val releaseAsset = release.getDownload()
if (releaseAsset == null) {
val noAssetException = Exception("missing Android download")
@@ -170,51 +246,92 @@ class ReleaseManager private constructor(
return
}
// set an initial download size
_uiState.value = ReleaseManagerState.InDownload(0, releaseAsset.size)
val resourcesAsset = release.getResourcesDownload()
val outputFile = getTempFile()
var releaseSize = releaseAsset.size ?: 0L
val resourcesSize = resourcesAsset?.size ?: 0L
// set an initial download size
val initialSize = if (releaseAsset.size == null && resourcesAsset?.size == null) null else releaseSize + resourcesSize
_uiState.value = ReleaseManagerState.InDownload(0, initialSize)
val outputFile = getTempFile(LaunchUtils.geodeFilename)
// clone the file instance as renameTo may move the original file
val tempFilePath = outputFile.path
try {
val fileStream = DownloadUtils.downloadStream(
httpClient,
releaseAsset.url
) { progress, outOf ->
_uiState.value = ReleaseManagerState.InDownload(progress, outOf)
}
outputFile.parentFile?.mkdirs()
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(
fileStream,
outputFile.outputStream(),
zip,
outputFile,
geodeFile.name
)
}
// work around a permission issue from adb push
if (geodeFile.exists()) {
geodeFile.delete()
}
val renameSuccessful = runCatching {
outputFile.renameTo(geodeFile)
}.getOrDefault(false)
if (!renameSuccessful) {
// attempt a manual copy if rename fails for whatever reason
DownloadUtils.copyFile(outputFile, geodeFile)
}
} catch (e: Exception) {
sendError(e)
return
} finally {
val tempFileClone = File(tempFilePath)
try {
if (downloadFile.exists()) downloadFile.delete()
if (tempFileClone.exists()) tempFileClone.delete()
} catch (_: Exception) { }
} catch (_: IOException) {}
}
downloadLauncherUpdateIfNecessary()
if (resourcesAsset != null) {
if (!performResourceDownload(resourcesAsset, releaseSize)) {
return
}
}
// extraction performed
updatePreferences(release)
val currentTime = Clock.System.now().toEpochMilliseconds()
PreferenceUtils.get(applicationContext)
.setLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME, currentTime)
_uiState.value = ReleaseManagerState.Finished(true)
}
@@ -228,35 +345,104 @@ class ReleaseManager private constructor(
val sharedPreferences = PreferenceUtils.get(applicationContext)
val originalFileHash = sharedPreferences.getString(PreferenceUtils.Key.CURRENT_RELEASE_MODIFIED)
?: return false
?: return true
val currentFileHash = computeFileHash(geodeFile)
return originalFileHash != currentFileHash
}
private fun checkLauncherUpdate(launcherUpdate: DownloadableLauncherRelease) {
if (launcherUpdate.release.tagName != BuildConfig.VERSION_NAME) {
_availableLauncherUpdate.value = launcherUpdate
private suspend fun getLatestLauncherUpdate(): DownloadableLauncherRelease? {
if (BuildConfig.GOOGLE_PLAY_BUILD) {
// don't check for updates on Google Play builds
return null
}
return try {
releaseRepository.getLatestLauncherRelease()
} catch (e: Exception) {
sendError(e)
return null
}
}
private suspend fun downloadLauncherUpdateIfNecessary() {
val update = _availableLauncherUpdate.value ?: return
downloadLauncherUpdate(update)
suspend fun checkLauncherUpdate() {
if (BuildConfig.GOOGLE_PLAY_BUILD) return
val launcherRelease = getLatestLauncherUpdate() ?: return
// unconditionally track it, we're not using it for checks and it might be good to have
_availableLauncherUpdateDetails.value = launcherRelease
val launcherTag = launcherRelease.release.tagName
PreferenceUtils.get(applicationContext)
.setString(PreferenceUtils.Key.LAST_LAUNCHER_UPDATE, launcherTag)
if (launcherTag != BuildConfig.VERSION_NAME) {
_availableLauncherUpdateTag.value = launcherTag
}
}
fun checkCachedLauncherUpdate() {
if (BuildConfig.GOOGLE_PLAY_BUILD) return
val lastUpdate = PreferenceUtils.get(applicationContext)
.getString(PreferenceUtils.Key.LAST_LAUNCHER_UPDATE) ?: return
if (lastUpdate != BuildConfig.VERSION_NAME) {
_availableLauncherUpdateTag.value = lastUpdate
}
}
suspend fun downloadLatestLauncherUpdate(): Result<File> {
if (BuildConfig.GOOGLE_PLAY_BUILD) {
return Result.failure(UnsupportedOperationException(
"Launcher updates are not supported on Google Play builds"
))
}
val update = _availableLauncherUpdateDetails.value
?: getLatestLauncherUpdate()
?: return Result.failure(FileNotFoundException())
return downloadLauncherUpdate(update)
}
fun shouldUseCache(): Boolean {
if (BuildConfig.GOOGLE_PLAY_BUILD) {
return true
}
private suspend fun checkForNewRelease(isManual: Boolean = false) {
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()
if (!isManual && lastCheckTime > checkMinTime && !fileWasExternallyModified()) {
return lastCheckTime > checkMinTime && !fileWasExternallyModified()
}
fun afterUseCachedUpdate() {
checkCachedLauncherUpdate()
_uiState.tryEmit(ReleaseManagerState.Finished())
}
private suspend fun checkForNewRelease(isManual: Boolean = false) {
if (BuildConfig.GOOGLE_PLAY_BUILD) {
_uiState.value = ReleaseManagerState.Finished()
return
}
val sharedPreferences = PreferenceUtils.get(applicationContext)
if (!isManual && shouldUseCache()) {
afterUseCachedUpdate()
return
}
val release = try {
getLatestRelease()
} catch (e: Exception) {
@@ -264,22 +450,11 @@ class ReleaseManager private constructor(
return
}
val launcherRelease = try {
releaseRepository.getLatestLauncherRelease()
} catch (e: Exception) {
sendError(e)
return
}
if (launcherRelease != null) {
checkLauncherUpdate(launcherRelease)
}
val currentTime = Clock.System.now().toEpochMilliseconds()
sharedPreferences.setLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME, currentTime)
checkLauncherUpdate()
if (release == null) {
downloadLauncherUpdateIfNecessary()
val currentTime = Clock.System.now().toEpochMilliseconds()
sharedPreferences.setLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME, currentTime)
_uiState.value = ReleaseManagerState.Finished()
return
@@ -290,7 +465,8 @@ class ReleaseManager private constructor(
// check if an update is needed
if (latestVersion == currentVersion && !fileWasExternallyModified()) {
downloadLauncherUpdateIfNecessary()
val currentTime = Clock.System.now().toEpochMilliseconds()
sharedPreferences.setLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME, currentTime)
_uiState.value = ReleaseManagerState.Finished()
return
@@ -300,8 +476,6 @@ class ReleaseManager private constructor(
// check if the file was externally modified
if (!allowOverwriting && fileWasExternallyModified(true)) {
downloadLauncherUpdateIfNecessary()
sendError(UpdateException(UpdateException.Reason.EXTERNAL_FILE_IN_USE))
return
}
@@ -329,8 +503,14 @@ class ReleaseManager private constructor(
sharedPreferences.setString(PreferenceUtils.Key.CURRENT_RELEASE_MODIFIED, fileHash)
}
private fun computeFileHash(file: File): String =
file.source().buffer().readByteString().md5().hex()
private fun computeFileHash(file: File): String {
val sink = HashingSink.md5(blackholeSink())
file.source().buffer().use {
it.readAll(sink)
}
return sink.hash.hex()
}
private fun getGeodeOutputPath(): File {
val geodeName = LaunchUtils.geodeFilename
@@ -339,15 +519,40 @@ class ReleaseManager private constructor(
return File(geodeDirectory, geodeName)
}
private fun getTempFile(): File {
val geodeName = LaunchUtils.geodeFilename
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)
val tempFile = File.createTempFile(geodeName, null, geodeDirectory)
// 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.
*/
@@ -365,10 +570,14 @@ class ReleaseManager private constructor(
@OptIn(DelicateCoroutinesApi::class)
fun checkForUpdates(isManual: Boolean = false): StateFlow<ReleaseManagerState> {
if (!isInUpdate) {
if (isManual || !shouldUseCache()) {
_uiState.value = ReleaseManagerState.InUpdateCheck
updateJob = GlobalScope.launch {
checkForNewRelease(isManual)
}
} else {
afterUseCachedUpdate()
}
}
return _uiState.asStateFlow()
@@ -1,6 +1,7 @@
package com.geode.launcher.updater
import com.geode.launcher.utils.DownloadUtils.executeCoroutine
import com.geode.launcher.BuildConfig
import com.geode.launcher.utils.LaunchUtils
import kotlin.time.Clock
import kotlinx.datetime.DateTimeUnit
import kotlin.time.Instant
@@ -10,8 +11,10 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNamingStrategy
import kotlinx.serialization.json.okio.decodeFromBufferedSource
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttp
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.coroutines.executeAsync
import java.io.IOException
import java.net.URL
import kotlin.time.ExperimentalTime
@@ -28,6 +31,8 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
private const val GEODE_API_BASE = "https://api.geode-sdk.org/v1"
}
private val userAgent = "okhttp/${OkHttp.VERSION} (launcher=${BuildConfig.VERSION_NAME};arch=${LaunchUtils.applicationArchitecture})"
suspend fun getLatestLauncherRelease(): DownloadableLauncherRelease? {
val releasePath = "$GITHUB_API_BASE/repos/geode-sdk/android-launcher/releases/latest"
@@ -59,12 +64,13 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
.url(url)
.addHeader("Accept", "application/json")
.addHeader(GITHUB_API_HEADER, GITHUB_API_VERSION)
.addHeader("User-Agent", userAgent)
.build()
val call = httpClient.newCall(request)
val response = call.executeCoroutine()
return when (response.code) {
return call.executeAsync().use { response ->
when (response.code) {
200 -> {
val format = Json {
namingStrategy = JsonNamingStrategy.SnakeCase
@@ -72,7 +78,7 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
}
val release = format.decodeFromBufferedSource<List<Release>>(
response.body!!.source()
response.body.source()
)
(release.find { it.tagName != "nightly" })
@@ -94,7 +100,7 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
throw IOException(msg)
}
throw IOException("response 403: ${response.body!!.string()}")
throw IOException("response 403: ${response.body.string()}")
}
404 -> {
null
@@ -104,6 +110,7 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
}
}
}
}
@OptIn(ExperimentalTime::class)
private fun generateRateLimitMessage(resetTime: Instant): String {
@@ -121,12 +128,12 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
.url(url)
.addHeader("Accept", "application/json")
.addHeader(GITHUB_API_HEADER, GITHUB_API_VERSION)
.addHeader("User-Agent", userAgent)
.build()
val call = httpClient.newCall(request)
val response = call.executeCoroutine()
return when (response.code) {
return call.executeAsync().use { response ->
when (response.code) {
200 -> {
val format = Json {
namingStrategy = JsonNamingStrategy.SnakeCase
@@ -134,7 +141,7 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
}
val release = format.decodeFromBufferedSource<Release>(
response.body!!.source()
response.body.source()
)
release
@@ -155,7 +162,7 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
throw IOException(msg)
}
throw IOException("response 403: ${response.body!!.string()}")
throw IOException("response 403: ${response.body.string()}")
}
404 -> {
null
@@ -165,6 +172,7 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
}
}
}
}
@OptIn(ExperimentalSerializationApi::class)
suspend fun getLatestIndexRelease(gd: Long, prerelease: Boolean = false): DownloadableLoaderRelease? {
@@ -178,12 +186,12 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
val request = Request.Builder()
.url(url)
.addHeader("Accept", "application/json")
.addHeader("User-Agent", userAgent)
.build()
val call = httpClient.newCall(request)
val response = call.executeCoroutine()
return when (response.code) {
return call.executeAsync().use { response ->
when (response.code) {
200 -> {
val format = Json {
namingStrategy = JsonNamingStrategy.SnakeCase
@@ -191,7 +199,7 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
}
val body = format.decodeFromBufferedSource<LoaderPayload<LoaderVersion>>(
response.body!!.source()
response.body.source()
)
val release = body.payload ?: throw IOException("index error: ${body.error}")
@@ -207,3 +215,4 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
}
}
}
}
@@ -7,6 +7,7 @@ import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.AP
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.geode.launcher.BuildConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -53,7 +54,7 @@ class ReleaseViewModel(private val application: Application): ViewModel() {
val isInUpdate
get() = ReleaseManager.get(application).isInUpdate
val nextLauncherUpdate = ReleaseManager.get(application).availableLauncherUpdate
val nextLauncherUpdate = ReleaseManager.get(application).availableLauncherUpdateTag
var hasPerformedCheck = false
private set
@@ -89,6 +90,10 @@ class ReleaseViewModel(private val application: Application): ViewModel() {
}
fun runReleaseCheck(isManual: Boolean = false) {
if (BuildConfig.GOOGLE_PLAY_BUILD) {
return
}
hasPerformedCheck = true
viewModelScope.launch(Dispatchers.IO) {
@@ -17,10 +17,10 @@ object Constants {
const val SUPPORTED_VERSION_CODE_MIN = 37L
// anything below this version code shows a warning on the main menu
const val SUPPORTED_VERSION_CODE_MIN_WARNING = 40L
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 = 40L
const val SUPPORTED_VERSION_CODE = 41L
const val SUPPORTED_VERSION_STRING = "2.2.143"
const val SUPPORTED_VERSION_STRING = "2.2.144"
}
@@ -2,37 +2,63 @@ package com.geode.launcher.utils
import android.os.Build
import android.os.FileUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.Call
import okhttp3.Callback
import kotlinx.coroutines.withContext
import okhttp3.MediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.RequestBody
import okhttp3.ResponseBody
import okhttp3.coroutines.executeAsync
import okio.Buffer
import okio.BufferedSink
import okio.BufferedSource
import okio.FileSystem
import okio.ForwardingSink
import okio.ForwardingSource
import okio.HashingSink
import okio.Path.Companion.toOkioPath
import okio.Sink
import okio.Source
import okio.buffer
import okio.sink
import java.io.File
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.util.concurrent.TimeUnit
import java.util.zip.ZipInputStream
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
typealias ProgressCallback = (progress: Long, outOf: Long) -> Unit
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(
httpClient: OkHttpClient,
url: String,
onProgress: ProgressCallback? = null
): InputStream {
onProgress: ProgressCallback? = null,
): ResponseBody {
val request = Request.Builder()
.url(url)
.build()
@@ -50,7 +76,7 @@ object DownloadUtils {
val originalResponse = chain.proceed(chain.request())
originalResponse.newBuilder()
.body(ProgressResponseBody(
originalResponse.body!!, onProgress
originalResponse.body, onProgress
))
.build()
}
@@ -59,50 +85,67 @@ object DownloadUtils {
val progressClient = progressClientBuilder.build()
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 suspendCancellableCoroutine { continuation ->
this.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
if (continuation.isCancelled) {
return
return response.body
}
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) {
continuation.resume(response)
}
})
if (destination.isDirectory) {
destination.mkdirs()
} else {
destination.parentFile?.mkdirs()
continuation.invokeOnCancellation {
this.cancel()
destination.outputStream().use { destinationStream ->
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
// (runInterruptible allows it to cancel, otherwise it waits for the stream to finish)
runInterruptible {
inputStream.use {
outputStream.use {
inputStream.use { input ->
output.outputStream().use { out ->
// nice indentation
val zip = ZipInputStream(inputStream)
zip.use {
var entry = it.nextEntry
ZipInputStream(input).use { zip ->
var entry = zip.nextEntry
while (entry != null) {
if (entry.name == zipPath) {
it.copyTo(outputStream)
zip.copyTo(out)
return@runInterruptible
}
entry = it.nextEntry
entry = zip.nextEntry
}
// 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) {
// gotta love copying
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>
@@ -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.MediaStore
import java.io.File
import androidx.core.net.toUri
class FileUtils {
// copied from https://stackoverflow.com/questions/17546101/get-real-path-for-uri-android
@@ -86,7 +87,7 @@ class FileUtils {
val file = File(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)
}
@@ -53,6 +53,7 @@ object GamePackageUtils {
39L to "2.206",
40L to "2.2074", // ok
41L to "2.2081",
42L to "2.209",
)
/**
@@ -63,14 +64,25 @@ object GamePackageUtils {
return gameVersionMap[versionCode] ?: getGameVersionString(packageManager)
}
fun getSupportedUnifiedVersion(): String {
return gameVersionMap[Constants.SUPPORTED_VERSION_CODE] ?: Constants.SUPPORTED_VERSION_STRING
}
fun detectAbiMismatch(context: Context, packageInfo: PackageInfo, loadException: Error): Boolean {
val abi = LaunchUtils.applicationArchitecture
val isExtracted =
packageInfo.applicationInfo!!.flags and ApplicationInfo.FLAG_EXTRACT_NATIVE_LIBS == ApplicationInfo.FLAG_EXTRACT_NATIVE_LIBS
val isSplit = (packageInfo.applicationInfo?.splitSourceDirs?.size ?: 0) > 1
val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
packageInfo.longVersionCode
} else {
@Suppress("DEPRECATION")
packageInfo.versionCode.toLong()
}
val metadata =
"Geometry Dash metadata:\nSplit sources: $isSplit\nExtracted libraries: $isExtracted\nLauncher architecture: $abi"
"Geometry Dash metadata:\nSplit sources: $isSplit\nExtracted libraries: $isExtracted\nLauncher architecture: $abi\nGame version: $versionCode"
Log.i("GeodeLauncher", metadata)
// easiest check! these messages are hardcoded in bionic/linker/linker_phdr.cpp
@@ -10,6 +10,7 @@ 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.os.Build
import android.os.Environment
@@ -31,7 +32,6 @@ import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import com.geode.launcher.BuildConfig
import com.geode.launcher.R
import com.geode.launcher.UserDirectoryProvider
import com.geode.launcher.activityresult.GeodeOpenFileActivityResult
import com.geode.launcher.activityresult.GeodeOpenFilesActivityResult
import com.geode.launcher.activityresult.GeodeSaveFileActivityResult
@@ -191,10 +191,16 @@ object GeodeUtils {
@JvmStatic
fun restartGame() {
restartGame(false)
}
@JvmStatic
fun restartGame(safeMode: Boolean) {
activity.get()?.run {
packageManager.getLaunchIntentForPackage(packageName)?.also {
val mainIntent = Intent.makeRestartActivityTask(it.component)
mainIntent.putExtra("restarted", true)
mainIntent.putExtra("safe-mode", safeMode)
startActivity(mainIntent)
exitProcess(0)
}
@@ -207,24 +213,22 @@ object GeodeUtils {
val context = activity.get()!!
val pathFile = File(path)
val baseDirectory = LaunchUtils.getBaseDirectory(context)
val baseDirectory = LaunchUtils.getBaseDirectory(context, true)
val isInternalPath = pathFile.startsWith(baseDirectory)
val intent = if (isInternalPath) {
// TODO: figure out how to get this to point to the path it should be pointing at
// (the best i got was pointing at a file)
// val relativePath = pathFile.relativeTo(baseDirectory)
val relativePath = pathFile.relativeTo(baseDirectory)
val uri = DocumentsContract.buildDocumentUri(
"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 {
data = DocumentsContract.buildRootUri(
"${context.packageName}.user", UserDirectoryProvider.ROOT
)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
Intent.FLAG_GRANT_PREFIX_URI_PERMISSION or
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
)
setDataAndType(uri, DocumentsContract.Document.MIME_TYPE_DIR)
}
} else {
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
@@ -394,6 +398,15 @@ object GeodeUtils {
return ""
}
@JvmStatic
fun getGameVersionCode(): Long {
activity.get()?.run {
return GamePackageUtils.getGameVersionCode(packageManager)
}
return 0L
}
/**
* Returns the current version code of the launcher.
*/
@@ -403,7 +416,7 @@ object GeodeUtils {
}
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"
@@ -422,7 +435,7 @@ object GeodeUtils {
Manifest.permission.WRITE_EXTERNAL_STORAGE
)
return permissions.all {
permissions.all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
}
@@ -551,6 +564,7 @@ object GeodeUtils {
const val CAPABILITY_EXTENDED_INPUT = "extended_input"
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)
@@ -762,6 +776,11 @@ object GeodeUtils {
return true
}
@JvmStatic
fun getConnectivityManager(): ConnectivityManager? = activity.get()?.run {
getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
}
external fun nativeKeyUp(keyCode: Int, modifiers: Int)
external fun nativeKeyDown(keyCode: Int, modifiers: Int, isRepeating: Boolean)
external fun nativeActionScroll(scrollX: Float, scrollY: Float)
@@ -781,7 +800,7 @@ object GeodeUtils {
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)
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)
@@ -43,23 +43,29 @@ enum class ApplicationIcon {
DEFAULT,
GEODE,
PRIDE,
TRANS;
TRANS,
GREEN,
SAPPHIRE;
companion object {
fun fromId(id: String) = when (id) {
"default" -> ApplicationIcon.DEFAULT
"geode" -> ApplicationIcon.GEODE
"pride" -> ApplicationIcon.PRIDE
"trans" -> ApplicationIcon.TRANS
else -> ApplicationIcon.DEFAULT
"default" -> DEFAULT
"geode" -> GEODE
"pride" -> PRIDE
"trans" -> TRANS
"sapphire" -> SAPPHIRE
"green" -> GREEN
else -> DEFAULT
}
}
fun toId() = when (this) {
ApplicationIcon.PRIDE -> "pride"
ApplicationIcon.GEODE -> "geode"
ApplicationIcon.DEFAULT -> "default"
ApplicationIcon.TRANS -> "trans"
PRIDE -> "pride"
GEODE -> "geode"
DEFAULT -> "default"
TRANS -> "trans"
SAPPHIRE -> "sapphire"
GREEN -> "green"
}
}
@@ -96,5 +102,17 @@ object IconUtils {
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,6 +2,7 @@ package com.geode.launcher.utils
import android.content.Context
import android.os.Build
import com.geode.launcher.BuildConfig
import java.io.File
import java.io.Serializable
@@ -41,6 +42,9 @@ object LaunchUtils {
}
fun isGeodeInstalled(context: Context): Boolean {
@Suppress("SENSELESS_COMPARISON")
if (BuildConfig.PREBUNDLED_GEODE != null) return true
return getInstalledGeodePath(context) != null
}
@@ -48,10 +52,14 @@ object LaunchUtils {
* Returns the directory that Geode/the game should base itself off of.
*/
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)
// also, is getting the first item the correct choice here?? what do they mean
@Suppress("DEPRECATION")
val dir = context.externalMediaDirs.first()
context.externalMediaDirs.first()
}
// prevent having resources added to system gallery
// accessing this file every time the directory is read may be a little wasteful...
@@ -73,13 +81,23 @@ object LaunchUtils {
return File(getBaseDirectory(context), "save")
}
const val CRASH_INDICATOR_NAME = "lastSessionDidCrash"
val CRASH_INDICATOR_NAMES = setOf("lastSessionDidCrash", "last-pid", "last-crashed")
fun getCrashDirectory(context: Context): File {
val base = getBaseDirectory(context)
return File(base, "game/geode/crashlogs/")
}
fun getGeodeLogsDirectory(context: Context): File {
val base = getBaseDirectory(context)
return File(base, "game/geode/logs/")
}
fun getGeodeResourcesDirectory(context: Context): File {
val base = getBaseDirectory(context)
return File(base, "game/geode/resources/geode.loader/")
}
fun getLastCrash(context: Context): File? {
val crashDirectory = getCrashDirectory(context)
if (!crashDirectory.exists()) {
@@ -88,7 +106,7 @@ object LaunchUtils {
val children = crashDirectory.listFiles {
// 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() }
@@ -96,9 +114,9 @@ object LaunchUtils {
fun lastSessionCrashed(context: Context): Boolean {
val base = getCrashDirectory(context)
val crashIndicatorFile = File(base, CRASH_INDICATOR_NAME)
return crashIndicatorFile.exists()
return CRASH_INDICATOR_NAMES.any {
File(base, it).exists()
}
}
enum class LauncherError : Serializable {
@@ -127,36 +127,34 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
enum class Key {
LOAD_AUTOMATICALLY,
UPDATE_AUTOMATICALLY,
RELEASE_CHANNEL,
CURRENT_VERSION_TAG,
CURRENT_VERSION_TIMESTAMP,
THEME,
BLACK_BACKGROUND,
CURRENT_RELEASE_MODIFIED,
LAST_DISMISSED_UPDATE,
DISMISSED_GJ_UPDATE,
LAUNCH_ARGUMENTS,
LIMIT_ASPECT_RATIO,
DISPLAY_MODE,
FORCE_HRR,
RELEASE_CHANNEL_TAG,
DEVELOPER_MODE,
CLEANUP_APKS,
CUSTOM_SYMBOL_LIST,
CUSTOM_ASPECT_RATIO,
SCREEN_ZOOM,
SCREEN_ZOOM_FIT,
LIMIT_FRAME_RATE,
DISABLE_USER_THEME,
SELECTED_ICON,
DISABLE_REDESIGN,
WAIT_PERIOD,
LAST_UPDATE_CHECK_TIME
LAST_UPDATE_CHECK_TIME,
LAST_LAUNCHER_UPDATE,
DISABLE_UPDATE_CACHE,
PREBUNDLED_GEODE_MIGRATION_PERFORMED,
}
private fun defaultValueForBooleanKey(key: Key): Boolean {
return when (key) {
Key.UPDATE_AUTOMATICALLY, Key.FORCE_HRR -> true
Key.UPDATE_AUTOMATICALLY -> true
Key.DEVELOPER_MODE -> BuildConfig.DEBUG
else -> false
}
@@ -180,23 +178,18 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
return when (key) {
Key.LOAD_AUTOMATICALLY -> "PreferenceLoadAutomatically"
Key.UPDATE_AUTOMATICALLY -> "PreferenceUpdateAutomatically"
Key.RELEASE_CHANNEL -> "PreferenceReleaseChannel"
Key.CURRENT_VERSION_TAG -> "PreferenceCurrentVersionName"
Key.CURRENT_VERSION_TIMESTAMP -> "PreferenceCurrentVersionDescriptor"
Key.THEME -> "PreferenceTheme"
Key.BLACK_BACKGROUND -> "PreferenceBlackBackground"
Key.CURRENT_RELEASE_MODIFIED -> "PreferenceReleaseModifiedHash"
Key.LAST_DISMISSED_UPDATE -> "PreferenceLastDismissedUpdate"
Key.DISMISSED_GJ_UPDATE -> "PreferenceDismissedGJUpdate"
Key.LAUNCH_ARGUMENTS -> "PreferenceLaunchArguments"
Key.LIMIT_ASPECT_RATIO -> "PreferenceLimitAspectRatio"
Key.DISPLAY_MODE -> "PreferenceDisplayMode"
Key.FORCE_HRR -> "PreferenceForceHighRefreshRate"
Key.DISABLE_REDESIGN -> "PreferenceDisableRedesign"
Key.RELEASE_CHANNEL_TAG -> "PreferenceReleaseChannelTag"
Key.DEVELOPER_MODE -> "PreferenceDeveloperMode"
Key.CLEANUP_APKS -> "PreferenceCleanupPackages"
Key.CUSTOM_SYMBOL_LIST -> "PreferenceCustomSymbolList"
Key.CUSTOM_ASPECT_RATIO -> "PreferenceCustomAspectRatio"
Key.SCREEN_ZOOM -> "PreferenceScreenZoom"
Key.SCREEN_ZOOM_FIT -> "PreferenceScreenZoomFit"
@@ -205,6 +198,14 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
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"
}
}
@@ -6,17 +6,19 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.delay
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
const val MS_TO_SEC = 1000L
@Composable
fun useCountdownTimer(
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
time: Long,
time: Duration,
onCountdownFinish: () -> Unit
): Long {
var millisUntilFinished by remember {
mutableLongStateOf(time)
mutableStateOf(time)
}
var shouldBeCounting by remember {
@@ -28,9 +30,9 @@ fun useCountdownTimer(
return@LaunchedEffect
}
if (millisUntilFinished > 0) {
delay(MS_TO_SEC)
millisUntilFinished -= MS_TO_SEC
if (millisUntilFinished > Duration.ZERO) {
delay(1.seconds)
millisUntilFinished -= 1.seconds
} else {
onCountdownFinish()
}
@@ -58,5 +60,5 @@ fun useCountdownTimer(
}
}
return millisUntilFinished / MS_TO_SEC
return millisUntilFinished.inWholeSeconds
}
@@ -1,31 +1,45 @@
package org.cocos2dx.lib
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.Rect
import android.graphics.Typeface
import android.text.TextPaint
import android.text.TextUtils
import android.util.Log
import android.view.MotionEvent
import androidx.core.graphics.createBitmap
import java.lang.ref.WeakReference
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.*
import java.util.LinkedList
import kotlin.math.abs
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")
object Cocos2dxBitmap {
private lateinit var context: WeakReference<Context>
@JvmStatic
private external fun nativeInitBitmapDC(pWidth: Int, pHeight: Int, pPixels: ByteArray)
external fun nativeInitBitmapDC(pWidth: Int, pHeight: Int, pPixels: ByteArray)
fun setContext(context: Context) {
this.context = WeakReference(context)
}
/*
@JvmStatic
@Suppress("unused")
fun createTextBitmap(
string: String,
fontName: String,
@@ -55,7 +69,6 @@ object Cocos2dxBitmap {
1.0f
)
}
*/
@JvmStatic
@Suppress("unused")
@@ -79,19 +92,18 @@ object Cocos2dxBitmap {
strokeB: Float,
strokeSize: Float
) {
val bitmapTotalHeight: Int
val horizontalAlignment = alignment and 15
val verticalAlignment = alignment shr 4 and 15
val horizontalAlignment = alignment and 0x0F
val verticalAlignment = (alignment shr 4) and 0x0F
val pString2 = refactorString(string)
val paint = newPaint(fontName, fontSize, horizontalAlignment)
paint.setARGB(
MotionEvent.ACTION_MASK,
(255.0 * fontTintR.toDouble()).toInt(),
(255.0 * fontTintG.toDouble()).toInt(),
(255.0 * fontTintB.toDouble()).toInt()
255,
(255.0 * fontTintR).toInt(),
(255.0 * fontTintG).toInt(),
(255.0 * fontTintB).toInt()
)
val textProperty = computeTextProperty(pString2, width, height, paint)
bitmapTotalHeight = if (height == 0) {
val bitmapTotalHeight = if (height == 0) {
textProperty.totalHeight
} else {
height
@@ -101,7 +113,8 @@ object Cocos2dxBitmap {
var renderTextDeltaX = 0.0f
var renderTextDeltaY = 0.0f
if (shadow) {
paint.setShadowLayer(shadowBlur, shadowDX, shadowDY, -8553091)
val shadowColor = 0xff7d7d7du.toInt()
paint.setShadowLayer(shadowBlur, shadowDX, shadowDY, shadowColor)
bitmapPaddingX = abs(shadowDX)
bitmapPaddingY = abs(shadowDY)
if (shadowDX.toDouble() < 0.0) {
@@ -111,56 +124,47 @@ object Cocos2dxBitmap {
renderTextDeltaY = bitmapPaddingY
}
}
val bitmap = Bitmap.createBitmap(
val bitmap = try {
createBitmap(
textProperty.maxWidth + bitmapPaddingX.toInt(),
bitmapPaddingY.toInt() + bitmapTotalHeight,
Bitmap.Config.ARGB_8888
bitmapTotalHeight + bitmapPaddingY.toInt()
)
} catch (e: Exception) {
// bandaid fix, hopefully someone checks logs to see this
Log.e("Cocos2dxBitmap", "Error creating bitmap: $e")
nativeInitBitmapDC(0, 0, ByteArray(0))
return
}
val canvas = Canvas(bitmap)
val fontMetricsInt = paint.fontMetricsInt
var y = computeY(fontMetricsInt, height, textProperty.totalHeight, verticalAlignment)
val lines = textProperty.lines
val length = lines.size
for (i in 0 until length) {
val line = lines[i]
canvas.drawText(
line!!,
computeX(
line,
textProperty.maxWidth,
horizontalAlignment
).toFloat() + renderTextDeltaX,
y.toFloat() + renderTextDeltaY,
paint
)
for (line in lines) {
val x = computeX(line, textProperty.maxWidth, horizontalAlignment)
canvas.drawText(line, x + renderTextDeltaX, y + renderTextDeltaY, paint)
y += textProperty.heightPerLine
}
if (stroke) {
val paintStroke = newPaint(fontName, fontSize, horizontalAlignment)
paintStroke.style = Paint.Style.STROKE
paintStroke.strokeWidth = 0.5f * strokeSize
paintStroke.strokeWidth = strokeSize * 0.5f
paintStroke.setARGB(
MotionEvent.ACTION_MASK,
255,
strokeR.toInt() * MotionEvent.ACTION_MASK,
strokeG.toInt() * MotionEvent.ACTION_MASK,
strokeB.toInt() * MotionEvent.ACTION_MASK
)
var y2 = computeY(fontMetricsInt, height, textProperty.totalHeight, verticalAlignment)
val lines2 = textProperty.lines
val length2 = lines2.size
for (i2 in 0 until length2) {
val line2 = lines2[i2]
canvas.drawText(
line2!!,
computeX(
line2,
textProperty.maxWidth,
horizontalAlignment
).toFloat() + renderTextDeltaX,
y2.toFloat() + renderTextDeltaY,
paintStroke
)
y2 += textProperty.heightPerLine
var y = computeY(fontMetricsInt, height, textProperty.totalHeight, verticalAlignment)
val lines = textProperty.lines
for (line in lines) {
val x = computeX(line, textProperty.maxWidth, horizontalAlignment)
canvas.drawText(line, x + renderTextDeltaX, y + renderTextDeltaY, paintStroke)
y += textProperty.heightPerLine
}
}
initNativeObject(bitmap)
@@ -168,24 +172,24 @@ object Cocos2dxBitmap {
private fun newPaint(fontName: String, fontSize: Int, horizontalAlignment: Int): Paint {
val paint = Paint()
paint.color = -1
paint.color = Color.WHITE
paint.textSize = fontSize.toFloat()
paint.isAntiAlias = true
if (fontName.endsWith(".ttf")) {
try {
context.get()?.apply {
context.get()!!.apply {
paint.typeface = Cocos2dxTypefaces[this, fontName]
}
} 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)
}
} else {
paint.typeface = Typeface.create(fontName, Typeface.NORMAL)
}
when (horizontalAlignment) {
2 -> paint.textAlign = Paint.Align.RIGHT
3 -> paint.textAlign = Paint.Align.CENTER
HORIZONTALALIGN_RIGHT -> paint.textAlign = Paint.Align.RIGHT
HORIZONTALALIGN_CENTER -> paint.textAlign = Paint.Align.CENTER
else -> paint.textAlign = Paint.Align.LEFT
}
return paint
@@ -198,14 +202,14 @@ object Cocos2dxBitmap {
paint: Paint
): TextProperty {
val fm = paint.fontMetricsInt
val h = ceil((fm.bottom - fm.top).toDouble()).toInt()
val h = fm.bottom - fm.top
var maxContentWidth = 0
val lines = splitString(string, width, height, paint)
if (width != 0) {
maxContentWidth = width
} else {
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) {
maxContentWidth = temp
}
@@ -214,10 +218,10 @@ object Cocos2dxBitmap {
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) {
2 -> maxWidth
3 -> maxWidth / 2
HORIZONTALALIGN_RIGHT -> maxWidth
HORIZONTALALIGN_CENTER -> maxWidth / 2
else -> 0
}
}
@@ -229,14 +233,16 @@ object Cocos2dxBitmap {
verticalAlignment: Int
): Int {
val y = -fontMetricsInt.top
return if (constrainHeight <= totalHeight) {
y
} else when (verticalAlignment) {
1 -> -fontMetricsInt.top
2 -> -fontMetricsInt.top + (constrainHeight - totalHeight)
3 -> -fontMetricsInt.top + (constrainHeight - totalHeight) / 2
return if (constrainHeight > totalHeight) {
when (verticalAlignment) {
VERTICALALIGN_TOP -> -fontMetricsInt.top
VERTICALALIGN_CENTER -> -fontMetricsInt.top + (constrainHeight - totalHeight) / 2
VERTICALALIGN_BOTTOM -> -fontMetricsInt.top + (constrainHeight - totalHeight)
else -> y
}
} else {
y
}
}
private fun splitString(
@@ -244,56 +250,56 @@ object Cocos2dxBitmap {
maxWidth: Int,
maxHeight: Int,
paint: Paint
): Array<String?> {
val lines: Array<String?> = string.split("\\n").toTypedArray()
): List<String> {
val lines = string.split("\n")
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) {
val strList = LinkedList<String?>()
val strList = LinkedList<String>()
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))
} else {
strList.add(line)
}
if (maxLines > 0 && strList.size >= maxLines) {
break
}
}
if (maxLines > 0 && strList.size > maxLines) {
while (strList.size > maxLines) {
strList.removeLast()
}
}
val ret = arrayOfNulls<String>(strList.size)
strList.toArray(ret)
ret
} else if (maxHeight == 0 || lines.size <= maxLines) {
lines
strList
} else if (maxHeight != 0 && lines.size > maxLines) {
lines.take(maxLines)
} else {
val strList2 = LinkedList<String?>()
for (i in 0 until maxLines) {
strList2.add(lines[i])
}
val ret2 = arrayOfNulls<String>(strList2.size)
strList2.toArray(ret2)
ret2
lines
}
}
private fun divideStringWithMaxWidth(
string: String?,
string: String,
maxWidth: Int,
paint: Paint
): LinkedList<String?> {
val charLength = string!!.length
): LinkedList<String> {
val charLength = string.length
var start = 0
val strList = LinkedList<String?>()
val strList = LinkedList<String>()
var i = 1
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) {
val lastIndexOfSpace = string.substring(0, i).lastIndexOf(" ")
if (lastIndexOfSpace != -1 && lastIndexOfSpace > start) {
strList.add(string.substring(start, lastIndexOfSpace))
i = lastIndexOfSpace + 1
@@ -303,16 +309,20 @@ object Cocos2dxBitmap {
} else {
strList.add(string.substring(start, i))
}
while (i < charLength && string[i] == ' ') {
i++
}
start = i
}
i++
}
if (start < charLength) {
strList.add(string.substring(start))
}
return strList
}
@@ -320,19 +330,22 @@ object Cocos2dxBitmap {
if (pString.compareTo("") == 0) {
return " "
}
val strBuilder = StringBuilder(pString)
var start = 0
var index = strBuilder.indexOf("\n")
while (index != -1) {
start = if (index == 0 || strBuilder[index - 1].code == 10) {
start = if (index == 0 || strBuilder[index - 1] == '\n') {
strBuilder.insert(start, " ")
index + 2
} else {
index + 1
}
if (start > strBuilder.length || index == strBuilder.length) {
break
}
index = strBuilder.indexOf("\n", start)
}
return strBuilder.toString()
@@ -340,15 +353,10 @@ object Cocos2dxBitmap {
private fun initNativeObject(pBitmap: Bitmap) {
val pixels = getPixels(pBitmap)
if (pixels != null) {
nativeInitBitmapDC(pBitmap.width, pBitmap.height, pixels)
}
}
private fun getPixels(pBitmap: Bitmap?): ByteArray? {
if (pBitmap == null) {
return null
}
private fun getPixels(pBitmap: Bitmap): ByteArray {
val pixels = ByteArray(pBitmap.width * pBitmap.height * 4)
val buf = ByteBuffer.wrap(pixels)
buf.order(ByteOrder.nativeOrder())
@@ -361,13 +369,18 @@ object Cocos2dxBitmap {
private fun getFontSizeAccordingHeight(height: Int): Int {
val paint = Paint()
val bounds = Rect()
paint.typeface = Typeface.DEFAULT
var incrTextSize = 1
var foundDesiredSize = false
while (!foundDesiredSize) {
paint.textSize = incrTextSize.toFloat()
paint.getTextBounds("SghMNy", 0, "SghMNy".length, bounds)
val text = "SghMNy"
paint.getTextBounds(text, 0, text.length, bounds)
incrTextSize++
if (height - bounds.height() <= 2) {
foundDesiredSize = true
}
@@ -382,23 +395,19 @@ object Cocos2dxBitmap {
if (TextUtils.isEmpty(string)) {
return ""
}
val paint = TextPaint()
paint.typeface = Typeface.DEFAULT
paint.textSize = fontSize
return TextUtils.ellipsize(string, paint, width, TextUtils.TruncateAt.END).toString()
}
private class TextProperty constructor(
private class TextProperty(
val maxWidth: Int,
val heightPerLine: Int, pLines: Array<String?>
val heightPerLine: Int,
val lines: Collection<String>
) {
val lines: Array<String?>
val totalHeight: Int
init {
totalHeight = pLines.size * heightPerLine
lines = pLines
}
val totalHeight: Int = lines.size * heightPerLine
}
}
@@ -8,11 +8,13 @@ import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.Message
import android.util.Log
import android.text.InputType
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.Surface
import android.view.WindowInsets
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import androidx.annotation.RequiresApi
import androidx.appcompat.widget.AppCompatEditText
@@ -25,12 +27,54 @@ private const val HANDLER_CLOSE_IME_KEYBOARD = 3
private const val MS_TO_NS = 1_000_000
private const val SEC_TO_NS = 1_000_000_000.0
fun inputTypeForFlags(i: Int): Int {
if ((i and BaseRobTopActivity.KEYBOARD_INPUT_NUMERIC) != 0) {
return InputType.TYPE_NUMBER_FLAG_DECIMAL or InputType.TYPE_NUMBER_FLAG_SIGNED or InputType.TYPE_CLASS_NUMBER
}
if ((i and BaseRobTopActivity.KEYBOARD_INPUT_PASSWORD) != 0) {
return InputType.TYPE_TEXT_VARIATION_PASSWORD or InputType.TYPE_CLASS_TEXT
}
if ((i and BaseRobTopActivity.KEYBOARD_INPUT_MULTILINE) != 0) {
return InputType.TYPE_TEXT_FLAG_MULTI_LINE or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or InputType.TYPE_CLASS_TEXT
}
return InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS or InputType.TYPE_CLASS_TEXT
}
fun usesInlineKeyboard(): Boolean {
return Build.VERSION.SDK_INT >= 30
}
fun imeOptionsForFlags(i: Int): Int {
val i2 = if ((i and BaseRobTopActivity.KEYBOARD_INPUT_MULTILINE) == 0 || !usesInlineKeyboard())
EditorInfo.IME_ACTION_DONE else EditorInfo.IME_ACTION_NONE
return if (usesInlineKeyboard())
EditorInfo.IME_FLAG_NO_FULLSCREEN or EditorInfo.IME_FLAG_NO_EXTRACT_UI or i2
else i2
}
class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
companion object {
lateinit var cocos2dxGLSurfaceView: Cocos2dxGLSurfaceView
private lateinit var handler: Handler
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
fun openIMEKeyboard() {
val msg = Message()
@@ -50,6 +94,30 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
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
@@ -63,6 +131,10 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
var manualBackEvents = false
var controllerEventsEnabled = false
private set
var supportsResizeEvents = false
// 2.209+ events for keyboard and such
var sendNewCocosEvents = false
var cocos2dxEditText: AppCompatEditText? = null
set(value) {
@@ -84,9 +156,13 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
HANDLER_OPEN_IME_KEYBOARD -> {
if (cocos2dxEditText?.requestFocus() == true) {
cocos2dxEditText?.apply {
removeTextChangedListener(
cocos2dxTextInputWrapper
)
removeTextChangedListener(cocos2dxTextInputWrapper)
if (sendNewCocosEvents) {
inputType = inputTypeForFlags(BaseRobTopActivity.curKeyboardInputFlags)
imeOptions = imeOptionsForFlags(BaseRobTopActivity.curKeyboardInputFlags)
}
setText("")
val text = msg.obj as String
append(text)
@@ -99,16 +175,13 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
cocos2dxEditText,
0
)
Log.d("GLSurfaceView", "showSoftInput")
return
}
return
}
HANDLER_CLOSE_IME_KEYBOARD -> {
cocos2dxEditText?.apply {
removeTextChangedListener(
cocos2dxTextInputWrapper
)
removeTextChangedListener(cocos2dxTextInputWrapper)
(context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(
windowToken,
0
@@ -116,8 +189,10 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
}
requestFocus()
Log.d("GLSurfaceView", "HideSoftInput")
queueEvent {
cocos2dxRenderer.handleTextClosed()
}
return
}
else -> return
@@ -288,6 +363,103 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
if (!isInEditMode) {
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 {
@@ -347,67 +519,21 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
}
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
if (!useKeyboardEvents && !controllerEventsEnabled) {
if (!queueKeyboardEvent(keyCode, event, true)) {
return legacyKeyDown(keyCode, event)
}
return when (keyCode) {
// ignore system keys
KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_MUTE -> {
super.onKeyDown(keyCode, event)
}
else -> {
if (BaseRobTopActivity.blockBackButton) {
return true
}
queueEvent {
sendNextEventTimestamp(event.eventTime * MS_TO_NS)
if (useKeyboardEvents) {
GeodeUtils.nativeKeyDown(keyCode, event.modifiers, event.repeatCount != 0)
}
if (controllerEventsEnabled) {
GeodeUtils.internalKeyEvent(event.eventTime * MS_TO_NS, event.deviceId, event.source, event.keyCode, event.modifiers, true, event.repeatCount)
}
}
true
}
}
}
override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean {
if (!useKeyboardEvents && !controllerEventsEnabled) {
if (!queueKeyboardEvent(keyCode, event, false)) {
return super.onKeyUp(keyCode, event)
}
return when (keyCode) {
KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_MUTE -> {
super.onKeyUp(keyCode, event)
}
else -> {
if (event.repeatCount != 0 || BaseRobTopActivity.blockBackButton) {
return true
}
queueEvent {
sendNextEventTimestamp(event.eventTime * MS_TO_NS)
if (useKeyboardEvents) {
GeodeUtils.nativeKeyUp(keyCode, event.modifiers)
}
if (controllerEventsEnabled) {
GeodeUtils.internalKeyEvent(event.eventTime * MS_TO_NS, event.deviceId, event.source, event.keyCode, event.modifiers, false, event.repeatCount)
}
}
true
}
}
}
// i'm lazy.
// https://developer.android.com/develop/ui/views/touch-and-input/game-controllers/controller-input
private fun getCenteredAxis(
@@ -468,11 +594,15 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
}
override fun onGenericMotionEvent(event: MotionEvent?): Boolean {
if (!useKeyboardEvents && !controllerEventsEnabled) {
val hasGeodeEvents = useKeyboardEvents || controllerEventsEnabled
if (!hasGeodeEvents && !sendNewCocosEvents) {
return super.onGenericMotionEvent(event)
}
if (event?.action == MotionEvent.ACTION_SCROLL) {
val mouseX = event.x
val mouseY = event.y
val scrollX = event.getAxisValue(MotionEvent.AXIS_HSCROLL)
val scrollY = event.getAxisValue(MotionEvent.AXIS_VSCROLL)
val timestamp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
@@ -486,7 +616,11 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
}
if (controllerEventsEnabled) {
GeodeUtils.internalScrollEvent(timestamp, event.deviceId, event.source, scrollX, scrollY)
GeodeUtils.internalScrollEvent(timestamp, event.deviceId, event.source, scrollX, scrollY, mouseX, mouseY)
}
if (!hasGeodeEvents && sendNewCocosEvents) {
cocos2dxRenderer.handleScroll(mouseX, mouseY, scrollY, scrollX)
}
}
@@ -28,12 +28,29 @@ class Cocos2dxRenderer(private var handler: Cocos2dxGLSurfaceView) : GLSurfaceVi
@JvmStatic
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
private external fun nativeOnPause()
@JvmStatic
private external fun nativeOnResume()
@JvmStatic
private external fun nativeScroll(x: Float, y: Float, scrollY: Float, scrollX: Float)
@JvmStatic
private external fun nativeRender()
@@ -133,6 +150,18 @@ class Cocos2dxRenderer(private var handler: Cocos2dxGLSurfaceView) : GLSurfaceVi
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() {
nativeOnPause()
}
@@ -5,8 +5,10 @@ import android.text.TextWatcher
import android.widget.TextView.OnEditorActionListener
import android.text.Editable
import android.view.KeyEvent
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.TextView
import com.customRobTop.JniToCpp
class Cocos2dxTextInputWrapper(private val cocos2dxGLSurfaceView: Cocos2dxGLSurfaceView) : TextWatcher, OnEditorActionListener {
private var originText: String? = null
@@ -19,21 +21,33 @@ class Cocos2dxTextInputWrapper(private val cocos2dxGLSurfaceView: Cocos2dxGLSurf
this.originText = originText
}
override fun afterTextChanged(s: Editable) {
if (!isFullScreenEdit) {
var nModified = s.length - text!!.length
if (nModified > 0) {
cocos2dxGLSurfaceView.insertText(
s.subSequence(text!!.length, s.length).toString()
)
} else {
while (nModified < 0) {
override fun afterTextChanged(editable: Editable) {
if (isFullScreenEdit) {
return
}
val cText = text ?: return
val str = editable.toString()
val unchanged = str.commonPrefixWith(cText)
var unchangedUntil = unchanged.length
if (unchangedUntil > 0 && unchangedUntil < str.length && Character.isLowSurrogate(str[unchangedUntil])) {
unchangedUntil--
}
val codePointLength = cText.codePointCount(unchangedUntil, cText.length)
repeat(codePointLength) {
cocos2dxGLSurfaceView.deleteBackward()
nModified++
}
if (unchangedUntil < str.length) {
cocos2dxGLSurfaceView.insertText(
str.substring(unchangedUntil)
)
}
text = s.toString()
}
text = str
}
override fun beforeTextChanged(
@@ -47,22 +61,45 @@ class Cocos2dxTextInputWrapper(private val cocos2dxGLSurfaceView: Cocos2dxGLSurf
override fun onTextChanged(pCharSequence: CharSequence, start: Int, before: Int, count: Int) {}
override fun onEditorAction(textView: TextView, actionID: Int, keyEvent: KeyEvent?): Boolean {
if (cocos2dxGLSurfaceView.cocos2dxEditText == textView && isFullScreenEdit) {
for (i in originText!!.length downTo 1) {
val fullscreenIme = cocos2dxGLSurfaceView.cocos2dxEditText == textView && isFullScreenEdit
if (fullscreenIme) {
var text = textView.text.toString()
if (originText != text || !cocos2dxGLSurfaceView.sendNewCocosEvents) {
if (cocos2dxGLSurfaceView.sendNewCocosEvents) {
cocos2dxGLSurfaceView.queueEvent {
JniToCpp.textInputCaretToEnd()
}
}
repeat(originText!!.length) {
cocos2dxGLSurfaceView.deleteBackward()
}
var text = textView.text.toString()
if (!cocos2dxGLSurfaceView.sendNewCocosEvents) {
if (!text.endsWith('\n')) {
text += '\n'
}
}
if (text.isNotEmpty()) {
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.closeIMEKeyboard()
}
return false
}
}
@@ -1,28 +1,28 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<group android:scaleX="0.75" android:scaleY="0.75" android:translateX="128" android:translateY="128">
android:viewportWidth="1366"
android:viewportHeight="1366">
<group>
<clip-path
android:pathData="M0,0h1024v1024h-1024z"/>
android:pathData="M0,0h1366v1366h-1366z"/>
<path
android:pathData="M0,0h1024v1024h-1024z"
android:pathData="M0,0h1366v1366h-1366z"
android:fillColor="#F4D48E"/>
<path
android:pathData="M512,512m-512,0a512,512 0,1 1,1024 0a512,512 0,1 1,-1024 0"
android:pathData="M598,683m-512,0a512,512 0,1 1,1024 0a512,512 0,1 1,-1024 0"
android:fillColor="#F4D48E"/>
<path
android:pathData="M719.5,289C618,241.7 526,152.5 320.5,152.5C115,152.5 3.5,248 -8.5,293C-20.5,338 -8.2,391.5 -13.5,390.5L-33,706.5H1145L1137.5,460C1115.5,426.8 1043.5,399 1007,391.5C970.5,384 822.5,337 719.5,289Z"
android: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="M561.5,514.5C488.5,476.5 341,402 214.5,402C88,402 30.3,440.7 -8.5,461.5C-47.3,482.3 -54,485.2 -59,486.5L-68.5,808L563.5,814.5L561.5,514.5Z"
android: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="M1020.5,551C994,521.2 893.7,454.1 662.5,452.5C539.2,451.6 363,505.6 259,577.5C163.9,643.2 43,680 -13,686L-124,934.5H1187.5L1020.5,551Z"
android: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="M819.5,856C638,856 518,772.5 359,772.5C200,772.5 100,860.5 65,869L-94,949.5V1041.5L1177.5,1025.5L1032.5,738C1028,758 967.4,856 819.5,856Z"
android: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>
@@ -1,28 +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.75" android:scaleY="0.75" android:translateX="128" android:translateY="128">
android:viewportWidth="1366"
android:viewportHeight="1366">
<group>
<clip-path
android:pathData="M0,0h1024v1024h-1024z"/>
android:pathData="M0,0h1366v1366h-1366z"/>
<path
android:pathData="M0,0h1024v1024h-1024z"
android:pathData="M0,0h1366v1366h-1366z"
android:fillColor="#CC7C7C"/>
<path
android:pathData="M512,512m-512,0a512,512 0,1 1,1024 0a512,512 0,1 1,-1024 0"
android:pathData="M598,683m-512,0a512,512 0,1 1,1024 0a512,512 0,1 1,-1024 0"
android:fillColor="#CC7C7C"/>
<path
android:pathData="M719.5,289C618,241.7 526,152.5 320.5,152.5C115,152.5 3.5,248 -8.5,293C-20.5,338 -8.2,391.5 -13.5,390.5L-33,706.5H1145L1137.5,460C1115.5,426.8 1043.5,399 1007,391.5C970.5,384 822.5,337 719.5,289Z"
android: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="M561.5,514.5C488.5,476.5 341,402 214.5,402C88,402 30.3,440.7 -8.5,461.5C-47.3,482.3 -54,485.2 -59,486.5L-68.5,808L563.5,814.5L561.5,514.5Z"
android: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="M1020.5,551C994,521.2 893.7,454.1 662.5,452.5C539.2,451.6 363,505.6 259,577.5C163.9,643.2 43,680 -13,686L-124,934.5H1187.5L1020.5,551Z"
android: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="M819.5,856C638,856 518,772.5 359,772.5C200,772.5 100,860.5 65,869L-94,949.5V1041.5L1177.5,1025.5L1032.5,738C1028,758 967.4,856 819.5,856Z"
android: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>
@@ -1,28 +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.75" android:scaleY="0.75" android:translateX="128" android:translateY="128">
android:viewportWidth="1366"
android:viewportHeight="1366">
<group>
<clip-path
android:pathData="M0,0h1024v1024h-1024z"/>
android:pathData="M0,0h1366v1366h-1366z"/>
<path
android:pathData="M0,0h1024v1024h-1024z"
android:pathData="M0,0h1366v1366h-1366z"
android:fillColor="#65B8C2"/>
<path
android:pathData="M512,512m-512,0a512,512 0,1 1,1024 0a512,512 0,1 1,-1024 0"
android:pathData="M598,683m-512,0a512,512 0,1 1,1024 0a512,512 0,1 1,-1024 0"
android:fillColor="#65B8C2"/>
<path
android:pathData="M719.5,289C618,241.7 526,152.5 320.5,152.5C115,152.5 3.5,248 -8.5,293C-20.5,338 -8.2,391.5 -13.5,390.5L-33,706.5H1145L1137.5,460C1115.5,426.8 1043.5,399 1007,391.5C970.5,384 822.5,337 719.5,289Z"
android: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="M561.5,514.5C488.5,476.5 341,402 214.5,402C88,402 30.3,440.7 -8.5,461.5C-47.3,482.3 -54,485.2 -59,486.5L-68.5,808L563.5,814.5L561.5,514.5Z"
android: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="M1020.5,551C994,521.2 893.7,454.1 662.5,452.5C539.2,451.6 363,505.6 259,577.5C163.9,643.2 43,680 -13,686L-124,934.5H1187.5L1020.5,551Z"
android: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="M819.5,856C638,856 518,772.5 359,772.5C200,772.5 100,860.5 65,869L-94,949.5V1041.5L1177.5,1025.5L1032.5,738C1028,758 967.4,856 819.5,856Z"
android: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="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="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="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>
+10
View File
@@ -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="M240,880L240,710Q201,693 171.5,664.5Q142,636 121.5,600Q101,564 90.5,523Q80,482 80,440Q80,282 192,181Q304,80 480,80Q656,80 768,181Q880,282 880,440Q880,482 869.5,523Q859,564 838.5,600Q818,636 788.5,664.5Q759,693 720,710L720,880L240,880ZM320,800L360,800L360,720L440,720L440,800L520,800L520,720L600,720L600,800L640,800L640,658Q678,649 707.5,628Q737,607 757.5,578Q778,549 789,514Q800,479 800,440Q800,315 711.5,237.5Q623,160 480,160Q337,160 248.5,237.5Q160,315 160,440Q160,479 171,514Q182,549 202.5,578Q223,607 253,628Q283,649 320,658L320,800ZM420,600L540,600L480,480L420,600ZM340,520Q373,520 396.5,496.5Q420,473 420,440Q420,407 396.5,383.5Q373,360 340,360Q307,360 283.5,383.5Q260,407 260,440Q260,473 283.5,496.5Q307,520 340,520ZM620,520Q653,520 676.5,496.5Q700,473 700,440Q700,407 676.5,383.5Q653,360 620,360Q587,360 563.5,383.5Q540,407 540,440Q540,473 563.5,496.5Q587,520 620,520ZM480,800L480,800Q480,800 480,800Q480,800 480,800Q480,800 480,800Q480,800 480,800Q480,800 480,800Q480,800 480,800Q480,800 480,800Q480,800 480,800Q480,800 480,800Q480,800 480,800Q480,800 480,800Q480,800 480,800L480,800L480,800L480,800L480,800L480,800L480,800L480,800L480,800L480,800Z"/>
</vector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_green_background"/>
<foreground android:drawable="@drawable/ic_launcher_monochrome_shadow"/>
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_sapphire_background"/>
<foreground android:drawable="@drawable/ic_launcher_sapphire_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_sapphire_monochrome" />
</adaptive-icon>
+52 -6
View File
@@ -35,7 +35,7 @@
</string>
<string name="launcher_game_update_required_short">A Geometry Dash update is required.</string>
<string name="launcher_game_update_required">The currently installed version of Geometry Dash is no longer supported. Please update Geometry Dash to continue receiving Geode updates.</string>
<string name="launcher_game_update_required">The currently installed version of Geometry Dash is no longer supported. Please update Geometry Dash to version %1$s to continue receiving Geode updates.</string>
<string name="launcher_update_available">A new launcher update is available!</string>
<string name="launcher_update_dismiss">Dismiss</string>
@@ -53,6 +53,7 @@
<string name="launcher_error_view_logs">View logs</string>
<string name="launcher_error_copy_error_alt">Copy</string>
<string name="launcher_error_copy">Copy details</string>
<string name="launcher_error_view_details">View details</string>
<string name="launcher_error_export_crash">Share crash</string>
<string name="launcher_error_export_missing">No crash dump was found!</string>
<string name="launcher_error_device_info">Android %2$s, %1$s (%5$s)\nGD %4$s, Geode %3$s + v%6$s</string>
@@ -62,12 +63,16 @@
<string name="launcher_notification_update_success">Geode updated!</string>
<string name="launcher_notification_compatibility">Unsupported Geometry Dash version.</string>
<string name="launcher_notification_compatibility_description">An update is required to receive Geode updates.</string>
<string name="launcher_notification_forwards_compatibility_description">Geode is currently unavailable.</string>
<string name="launcher_notification_forwards_compatibility">Unsupported Geometry Dash version.</string>
<string name="launcher_notification_update_cta">Tap for more information.</string>
<string name="launcher_self_update_failed_title">Update failed</string>
<string name="launcher_self_update_failed">Failed to install launcher update: %1$s.</string>
<string name="launcher_self_update_progress">Updating Geode Launcher…</string>
<string name="launcher_update_download_failed">Downloading update failed: %1$s</string>
<string name="launcher_self_update_progress">Installing update…</string>
<string name="launcher_starting_game">Starting Geometry Dash…</string>
<string name="launcher_downloading_update">Downloading update…</string>
@@ -75,8 +80,9 @@
<string name="launcher_cancelled_manual">Launch paused.</string>
<string name="launcher_cancelled_navigated_away">Launch paused.</string>
<string name="launcher_cancelled_crash">Launch paused due to a crash.</string>
<string name="launcher_cancelled_crash_instant">Geode crashed.</string>
<string name="launcher_cancelled_error">Geode cannot be used.</string>
<string name="launcher_cancelled_outdated">A Geometry Dash update is required.</string>
<string name="launcher_cancelled_outdated">Geometry Dash version %1$s is required.</string>
<string name="launcher_cancelled_restart">Restart</string>
<string name="launcher_cancelled_resume">Resume</string>
<string name="launcher_cancelled_retry">Retry</string>
@@ -99,6 +105,7 @@
<string name="release_fetch_manual_check_required">Externally managed library in use, please manually check for updates if you want to overwrite it.</string>
<string name="release_fetch_external_in_use_short">Update failed. External library in use.</string>
<string name="release_fetch_hash_validation_failed_short">Update failed. Integrity check failed.</string>
<string name="release_fetch_no_internet_short">Update failed. Internet not connected.</string>
<string name="release_fetch_generic_short">Update failed. Please try again later.</string>
@@ -113,10 +120,10 @@
<string name="load_failed_crashed_description">A crash was reported during the last session. Try to:</string>
<string name="load_failed_recommendation_reinstall">Reinstall Geometry Dash.</string>
<string name="load_failed_recommendation_switch_32bit_abi">Install the 32-bit version of the launcher.</string>
<string name="load_failed_recommendation_switch_64bit_abi">Install the non 32-bit version of the launcher.</string>
<string name="load_failed_recommendation_switch_64bit_abi">Install the non-32-bit version of the launcher.</string>
<string name="load_failed_recommendation_update">Verify that the latest versions of the launcher and Geometry Dash are installed.</string>
<string name="load_failed_recommendation_report">Report this issue to the developers.</string>
<string name="load_failed_recommendation_safe_mode">Enable the safe mode button in Settings.</string>
<string name="load_failed_recommendation_safe_mode">Use the safe mode button in Settings.</string>
<string name="load_failed_recommendation_dev_stl">Devs: If this is a locally built Geode version, please use the c++_shared ANDROID_STL!</string>
<string name="message_box_cancel">Cancel</string>
@@ -140,6 +147,7 @@
<string name="preferences_open_file_manager">Open file manager</string>
<string name="preferences_view_logs">View application logs</string>
<string name="preferences_view_crashes">View crash dumps</string>
<string name="preferences_view_geode_logs">View Geode logs</string>
<string name="preference_theme_name">Theme</string>
<string name="preference_theme_select">Select theme</string>
@@ -229,7 +237,12 @@
<string name="preference_check_for_updates_button">Check for updates</string>
<string name="preference_check_for_updates_description">Last checked %1$s</string>
<string name="preference_check_for_updates_failed">Update failed. See logs for more details.</string>
<string name="preference_check_for_updates_failed">Update failed.</string>
<string name="preference_check_for_updates_failed_action">Help</string>
<string name="preference_check_for_updates_help_title">Update failed.</string>
<string name="preference_check_for_updates_help_copy">Copy</string>
<string name="preference_check_for_updates_none_found">No updates found.</string>
<string name="preference_check_for_updates_success">Geode has been updated!</string>
<string name="preference_check_for_updates_downloading">Downloading update…</string>
@@ -241,10 +254,34 @@
<string name="preference_launch_safe_mode">Launch in safe mode</string>
<string name="preference_launch_safe_mode_confirm_title">Start in safe mode?</string>
<string name="preference_backup_cloud">Backup save to cloud</string>
<string name="preference_backup_confirm_title">Backup save data?</string>
<string name="preference_backup_in_progress">Backing up data…</string>
<string name="preference_backup_cancelling">Cancelling backup…</string>
<string name="preference_backup_confirm_description">This will backup your save data using your in-game account. You can then access it on an unmodified version of the game through the account backup menu.\nContinuing will overwrite any save data you currently have on the Geometry Dash servers!</string>
<string name="preference_backup_save_missing">Failed to find save data.</string>
<string name="preference_backup_save_decode_failed">Failed to decode save file.</string>
<string name="preference_backup_save_too_large">Save file is too large.</string>
<string name="preference_backup_save_details_missing">Failed to find account information.</string>
<string name="preference_backup_success">Backup success!</string>
<string name="preference_backup_cancelled">Backup cancelled.</string>
<string name="preference_backup_failed_generic">An unknown error has occurred.</string>
<string name="preference_backup_failed_invalid_login">Invalid login information.</string>
<string name="preference_backup_failed_network">A network error has occurred. Please try again later.</string>
<string name="preference_backup_failed_title">Backup failed</string>
<string name="preference_backup_failed">Backup failed with error code %1$d.</string>
<string name="preference_launch_arguments_name">Launch arguments</string>
<string name="preference_launch_arguments_set_title">Set launch arguments</string>
<string name="preference_text_clear">Clear</string>
<string name="preference_disable_update_cache">Disable update cache</string>
<string name="preference_open_developer_options">Open developer options</string>
<string name="preference_developer_options_dialog_title">Enable developer options?</string>
@@ -290,6 +327,8 @@
<string name="title_activity_crash_logs">Crash dumps</string>
<string name="application_crashes_no_dumps">No crash dumps available.</string>
<string name="application_crashes_delete_one">Delete</string>
<string name="application_crashes_clear_indicator">Ignore last crash</string>
<string name="application_crashes_create_indicator">Fake crash</string>
<!-- ApplicationIconActivity strings -->
<string name="title_activity_application_icon">Application icons</string>
@@ -298,6 +337,13 @@
<string name="application_icon_geode">Geode</string>
<string name="application_icon_pride">Pride</string>
<string name="application_icon_trans">Transgender</string>
<string name="application_icon_sapphire">Sapphire</string>
<string name="application_icon_green">Android</string>
<string name="application_icon_confirm">Change icon to %1$s?</string>
<string name="application_icon_confirm_description">Geode Launcher will close itself to change the application icon.</string>
<!-- GeodeLogsListingActivity strings -->
<string name="title_activity_geode_logs">Geode logs</string>
<string name="application_geode_logs_no_logs">No logs available.</string>
<string name="application_geode_logs_empty">File was empty.</string>
</resources>
+4 -8
View File
@@ -1,13 +1,9 @@
buildscript {
val composeBOM by extra("2026.01.01")
}
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id("com.android.application") version "9.0.0" apply false
id("com.android.library") version "9.0.0" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.3.0" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.3.0" apply false
id("com.android.application") version "9.4.1" apply false
id("com.android.library") version "9.4.1" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.4.21" apply false
}
tasks.register<Delete>("clean") {
Binary file not shown.
+3 -2
View File
@@ -1,8 +1,9 @@
#Wed Jan 28 04:12:33 MST 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+5 -8
View File
@@ -1,7 +1,7 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -20,7 +20,7 @@
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
@@ -29,7 +29,7 @@
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
@@ -57,7 +57,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@@ -114,7 +114,6 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
@@ -172,7 +171,6 @@ fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
@@ -212,8 +210,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
Vendored
+12 -24
View File
@@ -19,12 +19,12 @@
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@@ -51,7 +51,7 @@ echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
@@ -65,30 +65,18 @@ echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%