Compare commits

...
155 Commits
Author SHA1 Message Date
Chloe 208819436a actually keep it missing 2025-12-17 22:59:45 -07:00
Chloe d7c9e82fc2 fix missing bean 2025-12-17 22:53:00 -07:00
Chloe 8448b3f2a9 i am curious 2025-12-17 22:44:29 -07:00
Chloe 3a6c731710 use latest gradle 2025-12-17 16:16:21 -07:00
Chloe 2dde48c8fc update dependencies pt 1 2025-12-17 02:34:04 -07:00
Chloe f52b90b259 vnm 2025-09-29 01:06:44 -07:00
Chloe fa69a649dd always force hrr 2025-09-20 18:49:45 -07:00
Chloe a16c76cd06 color tweaks 2025-09-20 18:47:57 -07:00
Chloe 9b5d614d1a fix fit to screen background color 2025-09-20 18:28:29 -07:00
Chloe d6a6de8361 stop using alertdialog for settings 2025-09-20 18:23:31 -07:00
Chloe 2797e52683 cancel vibration with 0 2025-09-20 17:37:55 -07:00
Chloe 69b00fecf5 show current application icon in settings 2025-09-17 14:58:25 -07:00
Chloe f0eddd1652 add custom application icons 2025-09-17 14:53:52 -07:00
Chloe d7190fa928 sentence case
closes #40
2025-09-17 12:58:37 -07:00
Chloe 8e8a56da2e i figured out how to use fonts 2025-09-17 12:53:09 -07:00
Chloe b418e154da uninvert the settings colors 2025-09-17 12:24:36 -07:00
Chloe cd306a5b5e forgot the manifest 2025-09-17 12:22:53 -07:00
Chloe fb9a6d1912 add crash dump viewer 2025-09-17 12:22:47 -07:00
Chloe e276e302ab add option to disable user set colors 2025-09-17 10:48:16 -07:00
Chloe d7c779fce3 remove index api option 2025-09-17 10:42:18 -07:00
Chloe 1e0dcc91ec also hide button in generic crash 2025-09-17 10:38:06 -07:00
Chloe 266055e6d5 move applications logs to dev settings 2025-09-17 10:36:05 -07:00
Chloe ab8e3f83d2 add icon for file manager 2025-09-17 10:35:40 -07:00
Chloe a75d68e5e0 gatekeep developer settings 2025-09-14 20:03:29 -07:00
Chloe 13191dc66f invert settings coloring 2025-09-14 20:02:54 -07:00
Chloe b7f378b947 settingscard design tweak 2025-09-14 17:32:42 -07:00
Chloe 25367d5650 replace share button with save 2025-09-14 15:58:25 -07:00
Chloe aa51c201e5 maintenance 2025-09-14 15:54:26 -07:00
Chloe 0b92f4a318 implement internal callbacks 2025-06-22 14:45:04 -07:00
Chloe 7d75825d01 this breaks things and might fix others 2025-06-07 19:38:54 -07:00
Chloe c77271f45c some text input changes 2025-06-07 19:38:01 -07:00
Chloe 09bb28d66f remove unused import 2025-05-28 17:14:33 -07:00
Chloe 157952b6f7 download to temp file first 2025-05-28 17:11:22 -07:00
Chloe 352ff63ab0 also send timestamp for back 2025-05-03 16:33:19 -07:00
Chloe 91fc804dee bump version 2025-05-03 16:24:55 -07:00
Chloe 87808b56b3 remove premature bug fix 2025-05-03 16:22:21 -07:00
Chloe 2c2884c3b8 use indeterminate state instead of ellipses 2025-05-03 16:14:12 -07:00
Chloe 74e91e7dc1 document some unlisted methods 2025-05-03 14:29:40 -07:00
Chloe e3ffbf311e try it 2025-05-03 14:13:39 -07:00
Chloe 5ad77bca57 i forgot i never actually committed that 2025-05-03 03:27:25 -07:00
Chloe 3be0127e04 unfortunate 2025-05-03 03:24:39 -07:00
Chloe 458c837aba add index release api opt in 2025-05-03 03:16:29 -07:00
Chloe 67ca7707d8 support predictive back gestures 2025-04-24 11:59:31 -07:00
Chloe 776a4fa468 obligatory update commit 2025-04-24 11:00:12 -07:00
Chloe 9528eef313 bump tulip 2025-04-23 23:56:55 -07:00
Chloe 9827fe36c1 add screen insets utils 2025-04-23 23:56:54 -07:00
Oleksandr Nemesh ce44270b7a add webview util (#37) 2025-04-22 13:01:53 -07:00
Chloe 34e324c54d revert all refresh rate changes 2025-01-23 20:14:52 -07:00
Chloe 986e33ce03 this is dumb 2025-01-23 19:15:08 -07:00
Chloe 91c06bde8c i forgot about this 2025-01-23 13:16:56 -07:00
Chloe a9d40a7594 one log 2025-01-23 12:08:32 -07:00
Chloe bb6146be37 tweak zoom calculation 2025-01-23 12:05:30 -07:00
Chloe 071cf40d42 initial profiles support 2025-01-22 23:14:08 -07:00
Chloe 19f47f138a avoid some allocations in launcherfix 2025-01-22 22:18:36 -07:00
Chloe c070b5cfe0 smoother manual launch 2025-01-22 22:16:02 -07:00
Chloe 4782431bae add getLauncherVersion method 2025-01-22 17:07:18 -07:00
Chloe b63073c135 log it too 2025-01-21 22:45:35 -07:00
Chloe c029fed37d small state cleanup 2025-01-21 22:28:23 -07:00
Chloe dacabda792 original select code cleanup 2025-01-20 16:44:52 -07:00
Chloe 824d4395c7 cleanup some warnings 2025-01-20 15:28:33 -07:00
Chloe bd502b56c2 smarter gl config selection 2025-01-20 15:19:05 -07:00
Chloe 6991598fcb minor settings improvement 2025-01-20 15:18:27 -07:00
Chloe 2cc8e7215a bump 2025-01-20 06:05:02 -07:00
Chloe 5e81d1c70e old device things 2025-01-20 06:04:01 -07:00
Chloe b03ff6b735 small ui change 2025-01-20 05:36:31 -07:00
Chloe 220c1b0dd6 fix default zoom 2025-01-20 05:34:01 -07:00
Chloe 0a4fb091f4 forgot this bit too 2025-01-20 03:55:52 -07:00
Chloe 110f74400c bring back the accelerometer 2025-01-20 03:55:34 -07:00
Chloe eed8020229 move launcher update button to top 2025-01-19 23:39:05 -07:00
Chloe ceb3d5b461 hjfod's suggestion 2025-01-19 17:00:46 -07:00
Chloe 53cd339213 add fps limiting option 2025-01-19 04:27:23 -07:00
Chloe 327e9b4d39 add zoom control 2025-01-19 01:25:49 -07:00
Chloe 10a0670a47 show changelog for launcher updates 2025-01-18 18:17:53 -07:00
Chloe 500d3ba015 trim null characters in logs 2025-01-18 14:53:32 -07:00
Chloe 1236180425 move force hrr option 2025-01-18 14:31:51 -07:00
Chloe 129ee40c0d add custom aspect ratio option 2025-01-18 14:30:49 -07:00
Chloe a687f8464e make updater abstract 2025-01-18 03:23:13 -07:00
Chloe b409a200bb add version info to settings 2025-01-17 17:11:54 -07:00
Chloe acb07db6f2 add methods for vibration 2025-01-17 16:15:48 -07:00
Chloe 0ae65971fc workaround new kotlin bug 2025-01-17 15:23:41 -07:00
Chloe c4bd4333ca opt out of android 15 forced edge to edge 2025-01-17 15:01:37 -07:00
Chloe f9e30e549a avoid string copy in redirect_path 2025-01-17 14:55:05 -07:00
Chloe 1583f259b9 more dynamic select options 2025-01-17 14:54:42 -07:00
Chloe c090754129 prevent crash on null clipdata text 2024-11-22 15:27:48 -07:00
Chloe 31227860f4 disable custom symbol list option 2024-11-19 13:46:39 -07:00
Chloe edc2428f48 tweak font size 2024-11-19 13:43:44 -07:00
Chloe 6f3473b4ca yay! 2024-11-19 08:05:59 -07:00
Chloe f0af12f466 for the time being 2024-11-15 15:54:42 -07:00
Chloe 5bea87f98c just make it actually 16:9 2024-11-15 15:54:13 -07:00
Chloe 8617d900ee clear up some deprecations 2024-11-15 15:50:14 -07:00
Chloe 1d91357cad so i don't forget anymore 2024-11-15 15:37:18 -07:00
Chloe 1d495cb26e i wanted more padding 2024-11-15 15:11:07 -07:00
Chloe 31b5a8f16e two versions again 2024-11-15 15:08:31 -07:00
Cvolton b1c8690872 bump latest geode ver for 39 (again) 2024-11-15 11:24:44 +01:00
Cvolton c01470ac5a bump latest geode ver for 39 2024-11-14 02:01:21 +01:00
Chloe 98d3b76e4b final release step 2024-11-11 06:00:13 -07:00
Chloe 4a5e4ce85c my prediction was wrong 2024-11-11 05:49:31 -07:00
Chloe d3a3ab57a1 clean up unsupported version warning 2024-11-11 05:49:22 -07:00
Chloe 86a79f8cda hide it 2024-11-11 05:18:42 -07:00
Chloe 5fdf3127ae predicting pt 2 2024-11-11 04:48:04 -07:00
Chloe e9635160d1 i'm predicting 2024-11-11 04:47:42 -07:00
Chloe 4e6f99c745 gate refreshrate to android r 2024-11-03 06:28:06 -07:00
Chloe 1fd72ba8bc i overthought it 2024-11-03 06:11:41 -07:00
Chloe 03e12bf701 the usual 2024-11-03 05:46:57 -07:00
Chloe 4ae10dba7b receivers actually go unused 2024-08-08 02:21:39 -07:00
Chloe cad559f1d9 recreate the thing rob does 2024-08-08 01:57:10 -07:00
Chloe b60c564173 enable fragileuserdata flag 2024-07-27 17:31:18 -07:00
Chloe 7c21431efe honestly it was kinda funny 2024-07-07 21:54:48 -07:00
qimiko d345f4ccf8 read logs based on length instead of null 2024-06-24 20:28:37 -07:00
qimiko 6b70d8c6d0 force update on library corruption 2024-06-24 03:00:09 -07:00
qimiko 5f8e2b0ac3 the correct elf macros 2024-06-23 18:48:49 -07:00
qimiko fa7a8d96ea add check for null versionName 2024-06-22 20:39:28 -07:00
qimiko 291237d4f5 increment version 2024-06-22 19:35:50 -07:00
qimiko d4e85e3055 force c++ shared to load at start 2024-06-22 19:34:26 -07:00
qimiko 78992316e5 add logging to symbol patcher 2024-06-22 19:32:05 -07:00
qimiko 70333e3b5f keep the unwinds 2024-06-22 17:19:33 -07:00
qimiko 492e1ac11c increase info 2024-06-22 16:33:04 -07:00
qimiko 3daf5c48f0 null check on dlpi name 2024-06-22 15:29:49 -07:00
mat e9e250191c disable update-site for now 2024-06-22 18:44:31 -03:00
qimiko 9f5ccd9ef1 custom exceptions option 2024-06-22 02:34:18 -07:00
qimiko 3661df2667 good one 2024-06-21 16:17:54 -07:00
qimiko 759ee4afd7 very basic self updater 2024-06-21 06:18:32 -07:00
qimiko 375f771e8a add developer options menu 2024-06-20 23:07:09 -07:00
qimiko e6fa0d491d move settings 2024-06-20 20:34:11 -07:00
qimiko fa397dc036 mild catching up 2024-06-20 04:05:13 -07:00
qimiko c61d652229 add symbol renaming to launcherfix 2024-06-20 04:04:46 -07:00
qimiko 63b697e25c reorganize help button 2024-06-20 04:03:48 -07:00
qimiko d3fe2f0f41 left align again 2024-06-19 21:37:58 -07:00
qimiko cb50acca09 invert version selection 2024-06-19 21:16:38 -07:00
qimiko bdce61d160 improved geode version selection 2024-06-19 20:14:59 -07:00
qimiko a562f5ec75 add version support range 2024-06-19 03:40:00 -07:00
qimiko d21ab708dc remove background for now 2024-06-19 01:52:34 -07:00
qimiko ba642fb2d3 allow disabling automatic launch 2024-06-19 01:33:01 -07:00
qimiko 70934a95ad remove dismissing an update 2024-06-18 05:19:55 -07:00
qimiko 50f4a5d8e3 notification tweaks 2024-06-18 05:03:33 -07:00
qimiko d4bacc4afb fix restarting after rotation 2024-06-18 04:30:08 -07:00
qimiko 8942971368 reorganization 2024-06-18 04:24:00 -07:00
qimiko f801993d0e very important button 2024-06-16 04:39:18 -07:00
qimiko 0c551c8276 show update if necessary 2024-06-16 04:34:06 -07:00
qimiko 4d189f44a9 context aware restart button 2024-06-16 04:08:03 -07:00
qimiko 044af3d50b show more info in errors 2024-06-16 04:07:27 -07:00
qimiko e092c15004 safe mode option 2024-06-16 03:53:21 -07:00
qimiko 03bb6cd8b8 separate safe mode button into component 2024-06-16 02:45:41 -07:00
qimiko b1daf9c6b1 error view 2024-06-16 01:08:00 -07:00
qimiko 317a4b52cc notifications revamp 2024-06-16 01:07:15 -07:00
qimiko 033057fb6b base design 2024-06-14 05:40:09 -07:00
qimiko 1c463da5c7 redo launch logic 2024-06-14 05:00:21 -07:00
qimiko f8fcd81057 background tweaks 2024-06-11 22:25:03 -07:00
qimiko 97576dbfb4 initial background design 2024-06-11 16:10:20 -07:00
qimiko 0bd64b7578 minor notification change 2024-06-10 22:24:34 -07:00
qimiko b59cde68b0 initial concept 2024-06-10 22:23:42 -07:00
qimiko 8ea6589ab6 reorganize mainactivity components 2024-06-10 04:09:37 -07:00
qimiko 79bed774aa launcher settings notification on start 2024-06-09 23:03:11 -07:00
qimiko b919736ce1 store the current game version 2024-06-09 21:23:20 -07:00
qimiko d9a4f12902 check steam 2024-06-02 03:13:42 -07:00
92 changed files with 7781 additions and 2031 deletions

No files matched your search

+3 -3
View File
@@ -1,9 +1,9 @@
name: Update Website name: Update Website
on: on:
release: # release:
types: # types:
- released # - released
workflow_dispatch: workflow_dispatch:
+42 -21
View File
@@ -1,5 +1,13 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
val composeBOM: String by rootProject.extra val composeBOM: String by rootProject.extra
kotlin {
compilerOptions {
extraWarnings.set(true)
}
}
plugins { plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
@@ -8,14 +16,14 @@ plugins {
} }
android { android {
compileSdk = 34 compileSdk = 36
defaultConfig { defaultConfig {
applicationId = "com.geode.launcher" applicationId = "com.geode.launcher"
minSdk = 23 minSdk = 23
targetSdk = 34 targetSdk = 35
versionCode = 16 versionCode = 22
versionName = "1.3.4" versionName = "1.6.1"
vectorDrawables { vectorDrawables {
useSupportLibrary = true useSupportLibrary = true
@@ -24,7 +32,7 @@ android {
@Suppress("UnstableApiUsage") @Suppress("UnstableApiUsage")
externalNativeBuild { externalNativeBuild {
cmake { cmake {
arguments("-DUSE_TULIPHOOK:BOOL=OFF", "-DANDROID_STL=c++_shared") arguments("-DUSE_TULIPHOOK:BOOL=OFF", "-DANDROID_STL=c++_shared", "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES:BOOL=OFF")
} }
} }
@@ -62,8 +70,10 @@ android {
sourceCompatibility = JavaVersion.VERSION_1_8 sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8
} }
kotlinOptions { kotlin {
jvmTarget = "1.8" compilerOptions {
jvmTarget = JvmTarget.JVM_1_8
}
} }
buildFeatures { buildFeatures {
compose = true compose = true
@@ -76,30 +86,41 @@ android {
} }
externalNativeBuild { externalNativeBuild {
cmake { cmake {
version = "3.25.0+"
path = file("src/main/cpp/CMakeLists.txt") path = file("src/main/cpp/CMakeLists.txt")
} }
} }
namespace = "com.geode.launcher" namespace = "com.geode.launcher"
ndkVersion = "26.3.11579264" ndkVersion = "29.0.14206865"
} }
dependencies { dependencies {
implementation (platform("androidx.compose:compose-bom:$composeBOM")) implementation (platform("androidx.compose:compose-bom:$composeBOM"))
implementation ("androidx.core:core-ktx:1.13.1") implementation ("androidx.core:core-ktx:1.17.0")
implementation ("androidx.compose.ui:ui") implementation ("androidx.compose.ui:ui")
implementation ("androidx.compose.material3:material3") implementation ("androidx.compose.material3:material3")
implementation ("androidx.compose.ui:ui-tooling-preview") implementation ("androidx.compose.ui:ui-tooling-preview")
implementation ("androidx.lifecycle:lifecycle-runtime-ktx:2.8.1") implementation ("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
implementation ("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.1") implementation ("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
implementation ("androidx.activity:activity-compose:1.9.0") implementation ("androidx.activity:activity-compose:1.12.1")
implementation ("androidx.activity:activity-ktx:1.9.0") implementation ("androidx.activity:activity-ktx:1.12.1")
implementation ("androidx.appcompat:appcompat:1.7.0") implementation ("androidx.appcompat:appcompat:1.7.1")
implementation ("androidx.documentfile:documentfile:1.0.1") implementation ("androidx.documentfile:documentfile:1.1.0")
implementation ("com.squareup.okio:okio:3.9.0") implementation ("com.squareup.okio:okio:3.16.4")
implementation ("com.squareup.okhttp3:okhttp:4.12.0") implementation ("com.squareup.okhttp3:okhttp:5.3.2")
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json-okio:1.6.3") implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json-okio:1.9.0")
implementation ("org.jetbrains.kotlinx:kotlinx-datetime:0.6.0") implementation ("org.jetbrains.kotlinx:kotlinx-datetime:0.7.1")
implementation ("com.mikepenz:multiplatform-markdown-renderer-android:0.38.1")
implementation ("com.mikepenz:multiplatform-markdown-renderer-m3:0.38.1")
implementation ("androidx.browser:browser:1.9.0")
implementation ("io.ktor:ktor-server-core-jvm:3.3.3")
implementation ("io.ktor:ktor-server-cio:3.3.3")
implementation ("io.ktor:ktor-utils-jvm:3.3.3")
implementation ("io.ktor:ktor-server-status-pages:3.3.3")
implementation ("io.ktor:ktor-server-sse:3.3.3")
debugImplementation ("androidx.compose.ui:ui-tooling") debugImplementation ("androidx.compose.ui:ui-tooling")
coreLibraryDesugaring ("com.android.tools:desugar_jdk_libs:2.0.4") coreLibraryDesugaring ("com.android.tools:desugar_jdk_libs:2.1.5")
} }
+1
View File
@@ -5,3 +5,4 @@
# note: if you're going to add more rules, consider the @Keep annotation # note: if you're going to add more rules, consider the @Keep annotation
# this should really only be kept to non-custom code # this should really only be kept to non-custom code
-dontwarn java.lang.management.**
+77 -9
View File
@@ -9,15 +9,21 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" <uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" /> android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" <uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="32" /> android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" /> <uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" /> <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-feature <uses-feature
android:name="android.hardware.wifi" android:name="android.hardware.wifi"
@@ -33,22 +39,40 @@
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:hasFragileUserData="true"
android:requestLegacyExternalStorage="true" android:requestLegacyExternalStorage="true"
android:enableOnBackInvokedCallback="true"
android:theme="@style/Theme.GeodeLauncher" android:theme="@style/Theme.GeodeLauncher"
tools:targetApi="31"> tools:targetApi="35">
<activity <activity
android:name=".ApplicationLogsActivity" android:name=".AltMainActivity"
android:exported="false"
android:theme="@style/Theme.GeodeLauncher" />
<activity
android:name=".preferences.DeveloperSettingsActivity"
android:exported="false"
android:label="@string/title_activity_developer_settings"
android:theme="@style/Theme.GeodeLauncher" />
<activity
android:name=".preferences.ApplicationLogsActivity"
android:exported="false" android:exported="false"
android:label="@string/title_activity_application_logs" android:label="@string/title_activity_application_logs"
android:theme="@style/Theme.GeodeLauncher" /> android:theme="@style/Theme.GeodeLauncher" />
<activity <activity
android:name=".SettingsActivity" android:name=".preferences.CrashLogsActivity"
android:exported="false"
android:label="@string/title_activity_crash_logs"
android:theme="@style/Theme.GeodeLauncher" />
<activity
android:name=".preferences.ApplicationIconActivity"
android:exported="false"
android:label="@string/title_activity_application_icon"
android:theme="@style/Theme.GeodeLauncher" />
<activity
android:name=".preferences.SettingsActivity"
android:exported="false" android:exported="false"
android:label="@string/title_activity_settings" android:label="@string/title_activity_settings"
android:theme="@style/Theme.GeodeLauncher"> android:theme="@style/Theme.GeodeLauncher">
<meta-data
android:name="android.app.lib_name"
android:value="" />
</activity> </activity>
<activity <activity
android:name=".GeometryDashActivity" android:name=".GeometryDashActivity"
@@ -68,11 +92,49 @@
android:theme="@style/Theme.GeodeLauncher"> android:theme="@style/Theme.GeodeLauncher">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity-alias
android:name=".MainActivityGeode"
android:enabled="false"
android:exported="true"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher_geode"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
<activity-alias
android:name=".MainActivityPride"
android:enabled="false"
android:exported="true"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher_pride"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
<activity-alias
android:name=".MainActivityTrans"
android:enabled="false"
android:exported="true"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher_trans"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
<provider <provider
android:name=".UserDirectoryProvider" android:name=".UserDirectoryProvider"
android:authorities="com.geode.launcher.user" android:authorities="com.geode.launcher.user"
@@ -96,6 +158,12 @@
<meta-data <meta-data
android:name="android.game_mode_config" android:name="android.game_mode_config"
android:resource="@xml/game_mode_config" /> android:resource="@xml/game_mode_config" />
<service
android:name=".DeveloperServerService"
android:foregroundServiceType="dataSync"
android:exported="false">
</service>
</application> </application>
</manifest> </manifest>
@@ -0,0 +1,376 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Geode Android Web Interface</title>
<meta name="color-scheme" content="light dark">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Quicksand:wght@300..700&display=swap" rel="stylesheet">
<style>
.logo {
width: 1em;
height: 1em;
}
.title {
font-family: "Quicksand", sans-serif;
font-weight: 600;
font-size: 2rem;
display: flex;
align-items: center;
gap: 0.3em;
}
.listing-main {
display: flex;
}
body {
font-family: "Poppins", sans-serif;
}
#file-name {
font-family: "Quicksand", sans-serif;
font-weight: 600;
}
.hidden {
display: none;
}
#logs-area {
font-family: monospace;
font-size: 0.9em;
height: 15rem;
overflow-y: auto;
}
</style>
</head>
<body>
<h1 class="title">
<svg
width="698"
height="698"
viewBox="0 0 696 698"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="logo"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M393.076 24.4223L372.884 9.00248C358.192 -2.21718 337.808 -2.2172 323.116 9.00246L16.9433 242.812C3.28395 253.243 -2.41995 271.086 2.656 287.506L120.413 668.435C125.723 685.612 141.605 697.326 159.584 697.326H536.416C554.395 697.326 570.277 685.612 575.587 668.435L693.344 287.506C698.42 271.086 692.716 253.243 679.057 242.812L650.835 221.26L618.374 323.385L529.198 613.394C525.713 624.725 515.245 632.458 503.39 632.458H190.61C178.755 632.458 168.287 624.725 164.802 613.394L67.1963 295.971C63.8815 285.191 67.6162 273.487 76.562 266.619L330.558 71.6226C331.579 70.8386 332.643 70.1371 333.739 69.5183L393.076 24.4223Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M336.614 143.264C344.511 137.209 355.489 137.209 363.386 143.264L552.797 288.49C560.096 294.086 563.143 303.632 560.437 312.422L487.62 548.924C484.778 558.153 476.251 564.451 466.594 564.451H233.406C223.749 564.451 215.222 558.153 212.38 548.924L199.934 508.5H261.676C262.393 508.647 263.134 508.724 263.888 508.724H432.112C436.963 508.724 441.241 505.546 442.643 500.903L495.141 326.982C496.452 322.638 494.961 317.934 491.386 315.139L354.777 208.3C350.795 205.186 345.205 205.186 341.223 208.3L215.925 306.292L152.151 353.307L139.563 312.422C136.857 303.632 139.904 294.086 147.203 288.49L336.614 143.264Z"
fill="currentColor"
/>
</svg>
Geode Android Manager
</h1>
<div class="listing-main">
<section>
<label for="path-listing">
Directory Listing:
</label> <br />
<select id="path-listing" size=25>
</select>
<br />
<label for="path-name">Path:</label>
<input type="text" id="path-name" />
<button id="traverse-btn">Go</button>
<!--br />
<input type="file" id="upload-file">
<button id="upload-btn">Upload File</button-->
</section>
<section>
<h2><span id="file-name"></span></h2>
<div class="hidden" id="file-metadata">
<p>
Size: <span id="file-size"></span> <br />
Modified At: <span id="file-modified"></span> <br />
</p>
<p>
<!-- button id="file-replace">Replace</button>
<button id="file-delete">Delete</button>
<br /-->
<a href="" download id="file-download">Download</a>
</p>
</div>
</section>
</div>
<div>
<h2>Logs</h2>
<pre id="logs-area"></pre>
</div>
<script>
const basePath = "/";
/** @type {HTMLSelectElement | null} */
const pathListing = document.querySelector("#path-listing");
/** @type {HTMLInputElement | null} */
const pathName = document.querySelector("#path-name");
/** @type {HTMLButtonElement | null} */
const goBtn = document.querySelector("#traverse-btn");
/** @type {HTMLSpanElement | null} */
const fileName = document.querySelector("#file-name");
/** @type {HTMLSpanElement | null} */
const fileSize = document.querySelector("#file-size");
/** @type {HTMLSpanElement | null} */
const fileModified = document.querySelector("#file-modified");
/** @type {HTMLAnchorElement | null} */
const fileDownload = document.querySelector("#file-download");
/** @type {HTMLDivElement | null} */
const fileMetadata = document.querySelector("#file-metadata");
/** @type {HTMLPreElement | null} */
const logsArea = document.querySelector("#logs-area");
const currentListing = {};
function formatBytes(bytes, decimals = 2) {
if (bytes == 0) return "0 B";
const base = 1024;
const units = ["B", "KB", "MB", "GB", "TB"];
const unitIndex = Math.floor(Math.log(bytes) / Math.log(base));
const formattedValue = (bytes / Math.pow(base, unitIndex)).toFixed(decimals);
return `${formattedValue} ${units[unitIndex]}`;
};
async function fetchDirectoryListing(path) {
goBtn.disabled = true;
try {
const data = await fetch(`${basePath}files/${path}`);
const json = await data.json();
const selectElements = json.sort((a, b) => {
const aIsDir = a.type == "directory";
const bIsDir = b.type == "directory";
if (aIsDir && !bIsDir) {
return -1;
}
if (!aIsDir && bIsDir) {
return 1;
}
return a.filename.localeCompare(b.filename);
}).map((f) => {
currentListing[f.filename] = f;
const optionElement = document.createElement("option");
optionElement.value = f.filename;
if (f.type == "directory") {
optionElement.innerText = "📁 " + f.filename;
} else {
optionElement.innerText = f.filename;
}
return optionElement;
});
const parentElement = document.createElement("option");
parentElement.value = "..";
parentElement.innerText = "⤴️ ..";
selectElements.unshift(parentElement);
pathListing.replaceChildren(...selectElements);
pathName.value = path;
} catch (e) {
console.error(e);
} finally {
goBtn.disabled = false;
}
}
function updateFileData() {
if (pathListing.value == "..") {
return;
}
const currentFile = currentListing[pathListing.value];
fileName.innerText = currentFile.filename;
if (currentFile.type == "directory") {
fileMetadata.classList.add("hidden");
return;
}
fileMetadata.classList.remove("hidden");
fileSize.innerText = formatBytes(currentFile.size);
const modifiedDate = new Date(currentFile.lastModified);
fileModified.innerText = new Intl.DateTimeFormat("default", {
timeStyle: "long",
dateStyle: "long",
}).format(modifiedDate);
fileDownload.href = `${basePath}files${updatePath(currentFile.filename)}`;
}
function updatePath(append) {
const currentPath = pathName.value;
if (append == "..") {
if (currentPath == "/") {
return "/"
} else {
const path = currentPath.split("/");
path.pop();
return path.join("/");
}
}
if (currentPath.endsWith("/")) {
return currentPath + append;
} else {
return `${currentPath}/${append}`;
}
}
pathListing.addEventListener("dblclick", async (event) => {
const clickedOption = event.target.value;
if (clickedOption == "..") {
const newPath = updatePath("..");
await fetchDirectoryListing(newPath);
return;
}
const selected = currentListing[clickedOption];
if (selected.type == "directory") {
const newPath = updatePath(selected.filename);
await fetchDirectoryListing(newPath);
} else {
fileDownload.click();
}
});
pathListing.addEventListener("change", (event) => {
const clickedOption = event.target.value;
updateFileData();
});
goBtn.addEventListener("click", async () => {
const goto = pathName.value;
await fetchDirectoryListing(goto);
});
function formatLog(line) {
/*
{
"process": {
"processId": 31123,
"threadId": 31123,
"processUid": 10486
},
"time": "2025-12-17T10:38:19.488696104Z",
"logId": 4,
"priority": "error",
"tag": "AndroidRuntime",
"message": "..."
},
*/
return `[${line.priority.charAt(0)}] ${line.time} - [${line.tag}] ${line.message}`
}
async function updateLogs() {
const data = await fetch(`${basePath}logs`);
const json = await data.json();
const lines = json.map((l) => formatLog(l));
const message = lines.join("\n");
logsArea.innerText = message;
logsArea.scrollTop = logsArea.scrollHeight - logsArea.clientHeight
}
const unprocessedLogs = [];
function onTick() {
const batch = unprocessedLogs.splice(0, 100);
if (batch.length == 0) {
return;
}
const formatted = batch.map((l) => formatLog(JSON.parse(l))).join("\n");
const isScrolledToBottom = (logsArea.scrollHeight - logsArea.clientHeight) <= logsArea.scrollTop + 1;
logsArea.innerText += `\n${formatted}`;
if (isScrolledToBottom) {
logsArea.scrollTop = logsArea.scrollHeight - logsArea.clientHeight
}
}
function animationFrame() {
onTick();
requestAnimationFrame(animationFrame);
}
function openLogsStream() {
const evtSource = new EventSource(`${basePath}logs_live`);
// we store new logs to process on the next frame, to avoid slowdowns when lots of logs come in at once
evtSource.addEventListener("log", (event) => {
unprocessedLogs.push(event.data);
});
}
document.addEventListener("DOMContentLoaded", async (event) => {
await fetchDirectoryListing("/");
await updateLogs();
openLogsStream();
requestAnimationFrame(animationFrame);
});
</script>
</body>
</html>
+3 -2
View File
@@ -13,7 +13,7 @@ option(USE_TULIPHOOK "enables choice of hooking library" OFF)
if (USE_TULIPHOOK) if (USE_TULIPHOOK)
FetchContent_Declare(TulipHook FetchContent_Declare(TulipHook
GIT_REPOSITORY https://github.com/geode-sdk/TulipHook.git GIT_REPOSITORY https://github.com/geode-sdk/TulipHook.git
GIT_TAG 70122be72da7787dc42813ba0a992f3612e7871e GIT_TAG v3.1.4
) )
set(TULIP_LINK_SOURCE ON CACHE INTERNAL "") set(TULIP_LINK_SOURCE ON CACHE INTERNAL "")
@@ -24,8 +24,9 @@ if (USE_TULIPHOOK)
target_link_libraries(launcherfix PRIVATE TulipHook TulipHookInclude) target_link_libraries(launcherfix PRIVATE TulipHook TulipHookInclude)
else() else()
FetchContent_Declare(dobby FetchContent_Declare(dobby
# this is the last version that builds on android at all
GIT_REPOSITORY https://github.com/jmpews/Dobby GIT_REPOSITORY https://github.com/jmpews/Dobby
GIT_TAG 6a80a61ea062424d1595613058a174c64454fa57 GIT_TAG 0932d69c320e786672361ab53825ba8f4245e9d3
) )
FetchContent_GetProperties(dobby) FetchContent_GetProperties(dobby)
+224 -10
View File
@@ -1,7 +1,11 @@
#include <dlfcn.h> #include <dlfcn.h>
#include <jni.h> #include <jni.h>
#include <fstream>
#include <string> #include <string>
#include <vector>
#include <cstdint>
#include <android/log.h> #include <android/log.h>
#include <link.h>
#ifndef DISABLE_LAUNCHER_FIX #ifndef DISABLE_LAUNCHER_FIX
@@ -15,8 +19,9 @@
class DataPaths { class DataPaths {
public: public:
std::string original_data_path; std::string original_data_path{};
std::string data_path; std::string data_path{};
std::string load_symbols_from{};
static DataPaths& get_instance() { static DataPaths& get_instance() {
static auto paths_instance = DataPaths(); static auto paths_instance = DataPaths();
@@ -24,7 +29,7 @@ public:
} }
private: private:
DataPaths() : original_data_path(), data_path() {} DataPaths() {}
}; };
extern "C" extern "C"
@@ -41,6 +46,20 @@ JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setDataPath(
env->ReleaseStringUTFChars(data_path, data_path_str); env->ReleaseStringUTFChars(data_path, data_path_str);
} }
extern "C"
JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_enableCustomSymbolList(
JNIEnv* env,
jobject,
jstring symbol_path
) {
auto is_copy = jboolean();
auto symbol_path_str = env->GetStringUTFChars(symbol_path, &is_copy);
DataPaths::get_instance().load_symbols_from = std::string(symbol_path_str);
env->ReleaseStringUTFChars(symbol_path, symbol_path_str);
}
extern "C" extern "C"
JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setOriginalDataPath( JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setOriginalDataPath(
JNIEnv *env, JNIEnv *env,
@@ -55,9 +74,200 @@ JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setOriginalDataPath(
env->ReleaseStringUTFChars(data_path, data_path_str); env->ReleaseStringUTFChars(data_path, data_path_str);
} }
std::string redirect_path(const char* pathname) { // i copied this function from the android linker
auto data_path = DataPaths::get_instance().data_path; std::uint32_t elfhash(const char *_name) {
auto original_data_path = DataPaths::get_instance().original_data_path; 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;
}
// 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);
}
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;
if (!data_path.empty() && !original_data_path.empty()) { if (!data_path.empty() && !original_data_path.empty()) {
// call this a c string optimization // call this a c string optimization
@@ -68,22 +278,26 @@ std::string redirect_path(const char* pathname) {
} }
} }
return pathname; return std::nullopt;
} }
FILE* (*fopen_original)(const char *pathname, const char *mode); FILE* (*fopen_original)(const char *pathname, const char *mode);
FILE* fopen_hook(const char* pathname, const char* mode) { FILE* fopen_hook(const char* pathname, const char* mode) {
auto path_str = redirect_path(pathname); auto path_str = redirect_path(pathname);
auto redirect = path_str.has_value() ? path_str->c_str() : pathname;
return fopen_original(path_str.c_str(), mode); return fopen_original(redirect, mode);
} }
int (*rename_original)(const char* old_path, const char* new_path); int (*rename_original)(const char* old_path, const char* new_path);
int rename_hook(const char* old_path, const char* new_path) { int rename_hook(const char* old_path, const char* new_path) {
auto old_path_str = redirect_path(old_path); auto old_path_str = redirect_path(old_path);
auto new_path_str = redirect_path(new_path); auto old_redirect = old_path_str ? old_path_str->c_str() : old_path;
return rename_original(old_path_str.c_str(), new_path_str.c_str()); 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) { bool hook_function(void* addr, auto* hook, auto** orig) {
@@ -2,7 +2,6 @@ package com.customRobTop
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.app.KeyguardManager
import android.content.* import android.content.*
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
@@ -11,7 +10,6 @@ import android.os.Build
import android.provider.Settings import android.provider.Settings
import android.util.Log import android.util.Log
import android.widget.Toast import android.widget.Toast
import com.customRobTop.JniToCpp.resumeSound
import org.cocos2dx.lib.Cocos2dxGLSurfaceView.Companion.closeIMEKeyboard import org.cocos2dx.lib.Cocos2dxGLSurfaceView.Companion.closeIMEKeyboard
import org.cocos2dx.lib.Cocos2dxGLSurfaceView.Companion.openIMEKeyboard import org.cocos2dx.lib.Cocos2dxGLSurfaceView.Companion.openIMEKeyboard
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
@@ -26,7 +24,7 @@ object BaseRobTopActivity {
var isPaused = false var isPaused = false
lateinit var me: WeakReference<Activity> var me: WeakReference<Activity> = WeakReference(null)
private var shouldResumeSound = true private var shouldResumeSound = true
fun setCurrentActivity(currentActivity: Activity) { fun setCurrentActivity(currentActivity: Activity) {
@@ -259,29 +257,4 @@ object BaseRobTopActivity {
level = DeprecationLevel.HIDDEN level = DeprecationLevel.HIDDEN
) )
fun logEvent(event: String) {} fun logEvent(event: String) {}
class ReceiverScreen : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
Intent.ACTION_SCREEN_ON -> {
Log.d("TAG", "ACTION_SCREEN_ON")
if (!(me.get()?.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager).isKeyguardLocked) {
shouldResumeSound = true
}
if (!isPaused && shouldResumeSound) {
resumeSound()
}
}
Intent.ACTION_SCREEN_OFF -> {
shouldResumeSound = false
}
Intent.ACTION_USER_PRESENT -> {
shouldResumeSound = true
if (!isPaused) {
resumeSound()
}
}
}
}
}
} }
@@ -0,0 +1,523 @@
package com.geode.launcher
import android.os.Bundle
import android.text.format.Formatter
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.geode.launcher.main.*
import com.geode.launcher.ui.theme.GeodeLauncherTheme
import com.geode.launcher.ui.theme.LocalTheme
import com.geode.launcher.ui.theme.Theme
import com.geode.launcher.ui.theme.launcherTitleStyle
import com.geode.launcher.utils.Constants
import com.geode.launcher.utils.GamePackageUtils
import com.geode.launcher.utils.LaunchUtils
import com.geode.launcher.utils.PreferenceUtils
import kotlinx.coroutines.launch
class AltMainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
clearDownloadedApks(this)
val gdInstalled = GamePackageUtils.isGameInstalled(packageManager)
val geodeInstalled = LaunchUtils.isGeodeInstalled(this)
val returnMessage = intent.extras?.getString(LaunchUtils.LAUNCHER_KEY_RETURN_MESSAGE)
val returnExtendedMessage = intent.extras?.getString(LaunchUtils.LAUNCHER_KEY_RETURN_EXTENDED_MESSAGE)
@Suppress("DEPRECATION") // the new api is android 13 only (why)
val returnError = intent.extras?.getSerializable(LaunchUtils.LAUNCHER_KEY_RETURN_ERROR)
as? LaunchUtils.LauncherError
val loadFailureInfo = if (returnError != null && returnMessage != null) {
LoadFailureInfo(returnError, returnMessage, returnExtendedMessage)
} else if (LaunchUtils.lastSessionCrashed(this)) {
LoadFailureInfo(LaunchUtils.LauncherError.CRASHED)
} else { null }
setContent {
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
val theme = Theme.fromInt(themeOption)
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
val launchViewModel = viewModel<LaunchViewModel>(factory = LaunchViewModel.Factory)
if (loadFailureInfo != null) {
launchViewModel.loadFailure = loadFailureInfo
}
CompositionLocalProvider(LocalTheme provides theme) {
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
Surface(
modifier = Modifier.fillMaxSize()
) {
AltMainScreen(launchViewModel)
}
}
}
}
if (gdInstalled && geodeInstalled) {
intent.getBooleanExtra("restarted", false).let {
if (it) {
onLaunch(this)
}
}
}
}
}
data class LaunchStatusInfo(
val title: String?,
val details: String? = null,
val progress: (() -> Float)? = null
)
@Composable
fun LaunchCancelledIcon(cancelReason: LaunchViewModel.LaunchCancelReason, modifier: Modifier = Modifier) {
when (cancelReason) {
LaunchViewModel.LaunchCancelReason.GAME_OUTDATED,
LaunchViewModel.LaunchCancelReason.GAME_MISSING -> Icon(painterResource(R.drawable.icon_error), contentDescription = null, modifier)
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED,
LaunchViewModel.LaunchCancelReason.GEODE_NOT_FOUND -> Icon(painterResource(R.drawable.icon_warning), contentDescription = null, modifier)
else -> Icon(painterResource(R.drawable.icon_info), contentDescription = null, modifier)
}
}
@Composable
fun mapCancelReasonToInfo(cancelReason: LaunchViewModel.LaunchCancelReason): LaunchStatusInfo {
return when (cancelReason) {
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED -> LaunchStatusInfo(
title = stringResource(R.string.launcher_cancelled_crash)
)
LaunchViewModel.LaunchCancelReason.GEODE_NOT_FOUND -> LaunchStatusInfo(
title = stringResource(R.string.geode_download_title)
)
LaunchViewModel.LaunchCancelReason.GAME_MISSING -> LaunchStatusInfo(
title = stringResource(R.string.launcher_cancelled_error),
details = stringResource(R.string.game_not_found)
)
LaunchViewModel.LaunchCancelReason.GAME_OUTDATED -> LaunchStatusInfo(
title = stringResource(R.string.launcher_cancelled_error),
details = stringResource(R.string.launcher_cancelled_outdated)
)
LaunchViewModel.LaunchCancelReason.AUTOMATIC,
LaunchViewModel.LaunchCancelReason.MANUAL-> LaunchStatusInfo(
title = null
)
else -> LaunchStatusInfo(
title = stringResource(R.string.launcher_cancelled_manual)
)
}
}
@Composable
fun mapLaunchStatusToInfo(state: LaunchViewModel.LaunchUIState, inSafeMode: Boolean = false): LaunchStatusInfo {
return when (state) {
is LaunchViewModel.LaunchUIState.Initial,
is LaunchViewModel.LaunchUIState.Working,
is LaunchViewModel.LaunchUIState.Ready -> LaunchStatusInfo(
title = stringResource(R.string.launcher_starting_game),
details = if (inSafeMode) stringResource(R.string.launcher_in_safe_mode) else null
)
is LaunchViewModel.LaunchUIState.UpdateCheck -> LaunchStatusInfo(
title = stringResource(R.string.release_fetch_in_progress)
)
is LaunchViewModel.LaunchUIState.Updating -> {
val context = LocalContext.current
val downloaded = remember(state.downloaded) {
Formatter.formatShortFileSize(context, state.downloaded)
}
val outOf = remember(state.outOf) {
state.outOf?.run {
Formatter.formatShortFileSize(context, this)
}
}
LaunchStatusInfo(
title = stringResource(R.string.launcher_downloading_update),
details = stringResource(R.string.launcher_downloading_update_details, downloaded, outOf ?: "…"),
progress = {
val progress = state.downloaded / state.outOf!!.toDouble()
progress.toFloat()
}.takeIf { state.outOf != null }
)
}
is LaunchViewModel.LaunchUIState.Cancelled -> {
return mapCancelReasonToInfo(state.reason)
}
}
}
@Composable
fun LaunchCancelledBody(statusInfo: LaunchStatusInfo, icon: @Composable () -> Unit, inProgress: Boolean, modifier: Modifier = Modifier) {
if (statusInfo.title == null && statusInfo.details == null && !inProgress) {
return
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier.width(300.dp)
) {
if (statusInfo.title != null) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) {
CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodyLarge) {
icon()
Spacer(Modifier.size(8.dp))
Text(statusInfo.title)
}
}
}
if (inProgress) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
if (statusInfo.details != null) {
Text(
statusInfo.details,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center
)
}
}
}
@Composable
fun LaunchProgressBody(statusInfo: LaunchStatusInfo, modifier: Modifier = Modifier) {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.Start,
modifier = modifier.width(300.dp)
) {
if (statusInfo.title != null) {
Text(statusInfo.title, style = MaterialTheme.typography.bodyLarge)
}
if (statusInfo.progress != null) {
LinearProgressIndicator(
statusInfo.progress,
modifier = Modifier.fillMaxWidth()
)
} else {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
if (statusInfo.details != null) {
Text(statusInfo.details, style = MaterialTheme.typography.bodyMedium)
}
}
}
@Composable
fun RetryButtonContents(reason: LaunchViewModel.LaunchCancelReason) {
when (reason) {
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED -> {
Icon(
painterResource(R.drawable.icon_resume),
contentDescription = null
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_cancelled_resume))
}
LaunchViewModel.LaunchCancelReason.MANUAL,
LaunchViewModel.LaunchCancelReason.AUTOMATIC -> {
Icon(
painterResource(R.drawable.icon_play_arrow),
contentDescription = null
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_launch))
}
LaunchViewModel.LaunchCancelReason.GEODE_NOT_FOUND -> {
Icon(
painterResource(R.drawable.icon_refresh),
contentDescription = null
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_cancelled_retry))
}
// these shouldn't be allowing retries anyways
LaunchViewModel.LaunchCancelReason.GAME_MISSING,
LaunchViewModel.LaunchCancelReason.GAME_OUTDATED -> {}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun LaunchProgressCard(
uiState: LaunchViewModel.LaunchUIState,
crashInfo: LoadFailureInfo?,
onCancel: () -> Unit,
onResume: (safeMode: Boolean) -> Unit,
onMore: () -> Unit,
extraOptions: @Composable () -> Unit,
safeModeEnabled: Boolean,
modifier: Modifier = Modifier
) {
var showSafeModeDialog by remember { mutableStateOf(false) }
val status = mapLaunchStatusToInfo(uiState, safeModeEnabled)
Box(modifier = modifier) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (uiState is LaunchViewModel.LaunchUIState.Cancelled) {
LaunchCancelledBody(
statusInfo = status,
icon = { LaunchCancelledIcon(uiState.reason, Modifier.size(32.dp)) },
inProgress = uiState.inProgress
)
if (uiState.reason.isGameInstallIssue()) {
val context = LocalContext.current
val showDownload = remember { GamePackageUtils.showDownloadBadge(context.packageManager) }
if (showDownload) {
GooglePlayBadge(modifier = Modifier.align(Alignment.CenterHorizontally))
}
}
if (uiState.reason.allowsRetry()) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterHorizontally),
modifier = Modifier.align(Alignment.CenterHorizontally)
) {
LongPressButton(onClick = { onResume(false) }, onLongPress = {
showSafeModeDialog = true
}) {
RetryButtonContents(uiState.reason)
}
if (crashInfo != null) {
FilledTonalButton(onClick = onMore) {
Icon(
painterResource(R.drawable.icon_question_mark),
contentDescription = null
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_cancelled_help_alt))
}
}
extraOptions()
}
}
} else {
LaunchProgressBody(statusInfo = status, modifier = Modifier.padding(top = 16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.width(300.dp)) {
TextButton(onClick = onCancel) {
Text(stringResource(R.string.release_fetch_button_cancel))
}
Spacer(Modifier.weight(1.0f))
extraOptions()
}
}
}
}
if (showSafeModeDialog) {
SafeModeDialog(
onDismiss = {
showSafeModeDialog = false
},
onLaunch = {
onResume(true)
showSafeModeDialog = false
}
)
}
}
@Composable
fun ExtraOptions(onSettings: () -> Unit) {
IconButton(
onClick = onSettings,
) {
Icon(
painterResource(R.drawable.icon_settings),
contentDescription = stringResource(R.string.launcher_settings_icon_alt)
)
}
}
@Composable
fun GeodeLogo(modifier: Modifier = Modifier) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
) {
Icon(
painter = painterResource(id = R.drawable.geode_monochrome),
contentDescription = null,
modifier = Modifier
.size(64.dp, 64.dp),
)
Text(
stringResource(R.string.launcher_title),
style = launcherTitleStyle,
fontSize = 64.sp,
modifier = Modifier
.padding(12.dp)
)
}
}
@Composable
fun AltMainScreen(
launchViewModel: LaunchViewModel = viewModel(factory = LaunchViewModel.Factory)
) {
val context = LocalContext.current
LaunchedEffect(Unit) {
launchViewModel.beginLaunchFlow()
}
val launchUIState by launchViewModel.uiState.collectAsState()
val coroutineScope = rememberCoroutineScope()
var launchInSafeMode by remember { mutableStateOf(false) }
var showErrorInfo by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding(),
contentAlignment = Alignment.Center
) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(8.dp)
.verticalScroll(rememberScrollState())
) {
GeodeLogo()
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
LaunchProgressCard(
launchUIState,
crashInfo = launchViewModel.currentCrashInfo(),
onCancel = {
launchInSafeMode = false
coroutineScope.launch {
launchViewModel.cancelLaunch()
}
},
onResume = { safeMode ->
launchInSafeMode = safeMode
launchViewModel.clearCrashInfo()
coroutineScope.launch {
launchViewModel.beginLaunchFlow(true)
}
},
onMore = {
showErrorInfo = true
},
extraOptions = {
ExtraOptions(
onSettings = {
coroutineScope.launch {
launchViewModel.cancelLaunch(true)
}
onSettings(context)
}
)
},
safeModeEnabled = launchInSafeMode
)
// only show launcher update in a case where a user won't see it ingame
if (launchUIState is LaunchViewModel.LaunchUIState.Cancelled) {
val gameVersion = remember { GamePackageUtils.getGameVersionCodeOrNull(context.packageManager) }
if (gameVersion != null && gameVersion < Constants.SUPPORTED_VERSION_CODE_MIN_WARNING) {
UnsupportedVersionWarning()
}
val nextLauncherUpdate by launchViewModel.nextLauncherUpdate.collectAsState()
if (nextLauncherUpdate != null) {
LauncherUpdateIndicator()
}
}
}
}
}
if (launchUIState is LaunchViewModel.LaunchUIState.Ready) {
UpdateWarning(launchInSafeMode) {
launchInSafeMode = false
coroutineScope.launch {
launchViewModel.cancelLaunch()
}
}
}
val loadFailure = launchViewModel.currentCrashInfo()
if (showErrorInfo && loadFailure != null) {
ErrorInfoSheet(loadFailure, onDismiss = { showErrorInfo = false })
}
}
@@ -0,0 +1,351 @@
package com.geode.launcher
import android.app.Service
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Binder
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import com.geode.launcher.log.LogLine
import com.geode.launcher.utils.DownloadUtils
import com.geode.launcher.utils.LaunchUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerialName
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okio.Buffer
import okio.buffer
import okio.source
import java.io.EOFException
import java.io.File
import java.io.IOException
import java.nio.file.DirectoryNotEmptyException
import kotlin.collections.plusAssign
import kotlin.io.path.deleteExisting
import kotlin.io.path.exists
import kotlin.io.path.isDirectory
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Instant
import io.ktor.http.*
import io.ktor.http.content.PartData
import io.ktor.http.content.forEachPart
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.engine.*
import io.ktor.server.cio.*
import io.ktor.server.http.content.staticFiles
import io.ktor.server.plugins.statuspages.StatusPages
import io.ktor.server.request.receiveChannel
import io.ktor.server.request.receiveMultipart
import io.ktor.server.sse.*
import io.ktor.sse.ServerSentEvent
import io.ktor.util.cio.writeChannel
import io.ktor.utils.io.copyAndClose
enum class FileType {
@SerialName("file")
FILE,
@SerialName("directory")
DIRECTORY
}
@Serializable
data class FileMeta(val filename: String, val type: FileType, val size: Long, val lastModified: Instant)
class DeveloperServerService : Service() {
private val binder = LocalBinder()
private var server: EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration>? = null
inner class LocalBinder : Binder() {
fun getService(): DeveloperServerService = this@DeveloperServerService
}
private fun startForeground() {
try {
val notification = NotificationCompat.Builder(this, "geode_developer_server")
.setSmallIcon(R.drawable.geode_monochrome)
.build()
ServiceCompat.startForeground(
this,
100,
notification,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
} else {
0
},
)
} catch (e: Exception) {
/*
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
&& e is ForegroundServiceStartNotAllowedException
) {
println("idk")
}
*/
}
}
override fun onBind(intent: Intent): IBinder {
return binder
}
override fun onTimeout(startId: Int, fgsType: Int) {
stopSelf()
}
override fun onDestroy() {
super.onDestroy()
server?.stop()
server = null
}
// not recursive copy
private fun copyIndexToCache() {
val folderName = "web_interface"
val base = File(application.cacheDir, folderName)
base.deleteRecursively()
base.mkdirs()
assets.list(folderName)?.forEach {
val assetName = "$folderName/$it"
val outputPath = File(base, it)
assets.open(assetName).use { inputStream ->
outputPath.outputStream().use { outputStream ->
DownloadUtils.copyFile(inputStream, outputStream)
}
}
}
}
private fun createLogsFlow(): Flow<LogLine> = flow {
val timestamp = System.currentTimeMillis()
val seconds = timestamp / 1000
val ms = timestamp % 1000
// Time format is 'MM-DD hh:mm:ss.mmm...', 'YYYY-MM-DD hh:mm:ss.mmm...', or 'sssss.mmm...'.
val timeString = "$seconds.$ms"
val logCommand = "logcat -B -T $timeString"
val logProcess = try {
Runtime.getRuntime().exec(logCommand)
} catch (ioe: IOException) {
ioe.printStackTrace()
emit(LogLine.showException(ioe))
return@flow
}
val logBuffer = logProcess.inputStream.source().buffer()
try {
// this runs until the stream is exhausted
while (currentCoroutineContext().isActive) {
val line = LogLine.fromBufferedSource(logBuffer)
emit(line)
yield()
}
} catch (_: EOFException) {
// ignore, end of file reached
} catch (e: Exception) {
e.printStackTrace()
emit(LogLine.showException(e))
}
}
private suspend fun readLogs(): List<LogLine> {
val logLines = mutableListOf<LogLine>()
val logCommand = "logcat -B -d"
val logProcess = try {
withContext(Dispatchers.IO) {
Runtime.getRuntime().exec(logCommand)
}
} catch (ioe: IOException) {
ioe.printStackTrace()
logLines += LogLine.showException(ioe)
return logLines
}
// read entire log into a buffer so no logs are added to the buffer during processing
val logBuffer = Buffer()
logProcess.inputStream.source().buffer().readAll(logBuffer)
try {
coroutineScope {
// this runs until the stream is exhausted
while (true) {
val line = LogLine.fromBufferedSource(logBuffer)
logLines += line
yield()
}
}
} catch (_: EOFException) {
// ignore, end of file reached
} catch (e: Exception) {
e.printStackTrace()
logLines += LogLine.showException(e)
}
return logLines
}
override fun onCreate() {
super.onCreate()
val staticDir = File(application.cacheDir, "web_interface")
val baseDir = LaunchUtils.getBaseDirectory(application, true)
val logsFlow = createLogsFlow()
server = embeddedServer(CIO, port = 9043) {
routing {
install(SSE)
install(StatusPages) {
exception<Throwable> { call, cause ->
call.respondText(text = "500: $cause" , status = HttpStatusCode.InternalServerError)
}
}
get("/logs") {
val logs = Json.encodeToString(readLogs())
call.respondText(logs, contentType = ContentType.Application.Json)
}
post("/stop") {
stopSelf()
}
sse("/logs_live") {
heartbeat {
period = 5000.milliseconds
event = ServerSentEvent(comments = "heartbeat")
}
logsFlow.collect {
val log = Json.encodeToString(it)
send(log, event = "log", id = it.logId.toString())
}
}
staticFiles("/", staticDir)
staticFiles("/files", baseDir, index = null) {
fallback { requestedPath, call ->
val basePath = baseDir.toPath()
val builtPath = basePath.resolve(requestedPath).normalize()
if (!builtPath.startsWith(basePath) || !builtPath.exists()) {
// block path traversal issues
call.response.status(HttpStatusCode.NotFound)
} else {
val filenames = builtPath.toFile().listFiles()?.map {
FileMeta(
filename = it.name,
type = if (it.isDirectory) FileType.DIRECTORY else FileType.FILE,
size = it.length(),
lastModified = Instant.fromEpochMilliseconds(it.lastModified())
)
} ?: emptyList()
val data = Json.encodeToString(filenames)
call.respondText(data, contentType = ContentType.Application.Json)
}
}
}
delete("/files/{path...}") {
val pathSegments = call.parameters.getAll("path")
val requestedPath = pathSegments?.joinToString("/") ?: ""
val basePath = baseDir.toPath()
val builtPath = basePath.resolve(requestedPath).normalize()
if (!builtPath.startsWith(basePath) || !builtPath.exists()) {
call.response.status(HttpStatusCode.NotFound)
} else {
try {
builtPath.deleteExisting()
call.response.status(HttpStatusCode.NoContent)
} catch (e: DirectoryNotEmptyException) {
val message = Json.encodeToString(buildJsonObject {
put("message", "Provided directory $requestedPath was not empty")
})
call.respondText(message, status = HttpStatusCode.Conflict, contentType = ContentType.Application.Json)
}
}
}
put("/files/{path...}") {
val pathSegments = call.parameters.getAll("path")
val requestedPath = pathSegments?.joinToString("/") ?: ""
val basePath = baseDir.toPath()
val builtPath = basePath.resolve(requestedPath).normalize()
if (!builtPath.startsWith(basePath)) {
call.response.status(HttpStatusCode.NotFound)
} else {
if (builtPath.isDirectory()) {
val multipart = call.receiveMultipart(formFieldLimit = 1024 * 1024 * 100)
multipart.forEachPart { part ->
if (part is PartData.FileItem) {
val fileName = part.originalFileName ?: "uploaded_file"
val outputPath = builtPath.resolve(fileName).normalize()
if (!outputPath.startsWith(basePath)) {
val outputFile = outputPath.toFile()
part.provider().copyAndClose(outputFile.writeChannel())
}
}
part.dispose()
}
} else {
val outputFile = builtPath.toFile()
outputFile.parentFile?.mkdirs()
call.receiveChannel().copyAndClose(outputFile.writeChannel())
}
call.response.status(HttpStatusCode.NoContent)
}
}
}
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
this.startForeground()
copyIndexToCache()
server?.start(wait = false)
return START_STICKY
}
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
stopSelf()
}
}
@@ -1,26 +1,31 @@
package com.geode.launcher package com.geode.launcher
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.content.pm.ActivityInfo import android.content.pm.ActivityInfo
import android.content.pm.ApplicationInfo import android.content.pm.ApplicationInfo
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.opengl.GLSurfaceView
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Environment import android.os.Environment
import android.text.InputType
import android.util.Log import android.util.Log
import android.view.ViewGroup import android.view.ViewGroup
import android.view.WindowManager import android.view.WindowManager
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.AppCompatEditText
import androidx.compose.ui.platform.ComposeView
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.WindowInsetsControllerCompat
import com.customRobTop.BaseRobTopActivity import com.customRobTop.BaseRobTopActivity
import com.customRobTop.JniToCpp import com.customRobTop.JniToCpp
import com.geode.launcher.main.LaunchNotification
import com.geode.launcher.main.determineDisplayedCards
import com.geode.launcher.utils.Constants import com.geode.launcher.utils.Constants
import com.geode.launcher.utils.ConstrainedFrameLayout import com.geode.launcher.utils.ConstrainedFrameLayout
import com.geode.launcher.utils.DownloadUtils import com.geode.launcher.utils.DownloadUtils
@@ -28,7 +33,6 @@ import com.geode.launcher.utils.GamePackageUtils
import com.geode.launcher.utils.GeodeUtils import com.geode.launcher.utils.GeodeUtils
import com.geode.launcher.utils.LaunchUtils import com.geode.launcher.utils.LaunchUtils
import com.geode.launcher.utils.PreferenceUtils import com.geode.launcher.utils.PreferenceUtils
import org.cocos2dx.lib.Cocos2dxEditText
import org.cocos2dx.lib.Cocos2dxGLSurfaceView import org.cocos2dx.lib.Cocos2dxGLSurfaceView
import org.cocos2dx.lib.Cocos2dxHelper import org.cocos2dx.lib.Cocos2dxHelper
import org.cocos2dx.lib.Cocos2dxRenderer import org.cocos2dx.lib.Cocos2dxRenderer
@@ -37,6 +41,9 @@ import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import javax.microedition.khronos.egl.EGL10
import javax.microedition.khronos.egl.EGLConfig
import javax.microedition.khronos.egl.EGLDisplay
enum class DisplayMode { enum class DisplayMode {
DEFAULT, LIMITED, FULLSCREEN; DEFAULT, LIMITED, FULLSCREEN;
@@ -50,19 +57,29 @@ enum class DisplayMode {
} }
} }
fun ratioForPreference(value: String) = when (value) {
"4_3" -> 4/3.0f
"1_1" -> 1.0f
"16_10" -> 16/10.0f
else -> 1.77f
}
class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperListener, GeodeUtils.CapabilityListener { class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperListener, GeodeUtils.CapabilityListener {
private var mGLSurfaceView: Cocos2dxGLSurfaceView? = null private var mGLSurfaceView: Cocos2dxGLSurfaceView? = null
private val sTag = GeometryDashActivity::class.simpleName
private var mIsRunning = false private var mIsRunning = false
private var mIsOnPause = false private var mIsOnPause = false
private var mHasWindowFocus = false private var mHasWindowFocus = false
private var mReceiver: BroadcastReceiver? = null
private var displayMode = DisplayMode.DEFAULT private var displayMode = DisplayMode.DEFAULT
private var forceRefreshRate = false private var mForceRefreshRate = false
private var mLimitedRefreshRate: Int? = null
private var mAspectRatio = 0.0f
private var mScreenZoom = 1.0f
private var mScreenZoomFit = false
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
setupUIState() setupUIState()
FMOD.init(this)
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -73,6 +90,7 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
} }
try { try {
createVersionFile()
tryLoadGame() tryLoadGame()
} catch (e: UnsatisfiedLinkError) { } catch (e: UnsatisfiedLinkError) {
Log.e("GeodeLauncher", "Library linkage failure", e) Log.e("GeodeLauncher", "Library linkage failure", e)
@@ -85,6 +103,8 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
val errorMessage = when { val errorMessage = when {
abiMismatch && is64bit -> LaunchUtils.LauncherError.LINKER_NEEDS_32BIT abiMismatch && is64bit -> LaunchUtils.LauncherError.LINKER_NEEDS_32BIT
abiMismatch -> LaunchUtils.LauncherError.LINKER_NEEDS_64BIT abiMismatch -> LaunchUtils.LauncherError.LINKER_NEEDS_64BIT
e.message?.contains("__gxx_personality_v0") == true ->
LaunchUtils.LauncherError.LINKER_FAILED_STL
else -> LaunchUtils.LauncherError.LINKER_FAILED else -> LaunchUtils.LauncherError.LINKER_FAILED
} }
@@ -102,6 +122,20 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
return return
} }
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
mGLSurfaceView?.sendKeyBack()
}
})
mGLSurfaceView?.manualBackEvents = true
}
private fun createVersionFile() {
val versionPath = File(filesDir, "game_version.txt")
val gameVersion = GamePackageUtils.getGameVersionCode(packageManager)
versionPath.writeText("$gameVersion")
} }
private fun returnToMain( private fun returnToMain(
@@ -135,6 +169,20 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
tryLoadLibrary(gdPackageInfo, Constants.FMOD_LIB_NAME) tryLoadLibrary(gdPackageInfo, Constants.FMOD_LIB_NAME)
tryLoadLibrary(gdPackageInfo, Constants.COCOS_LIB_NAME) tryLoadLibrary(gdPackageInfo, Constants.COCOS_LIB_NAME)
if (GamePackageUtils.getGameVersionCode(packageManager) >= 39L) {
/*
val 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)
}
*/
// 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()
}
loadInternalMods() loadInternalMods()
setContentView(createView()) setContentView(createView())
@@ -172,6 +220,9 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
val saveDir = LaunchUtils.getSaveDirectory(this) val saveDir = LaunchUtils.getSaveDirectory(this)
saveDir.mkdir() saveDir.mkdir()
// make sure it's loaded, just in case
System.loadLibrary("c++_shared")
LauncherFix.loadLibrary() LauncherFix.loadLibrary()
LauncherFix.setOriginalDataPath(Constants.GJ_DATA_DIR) LauncherFix.setOriginalDataPath(Constants.GJ_DATA_DIR)
LauncherFix.setDataPath(saveDir.path) LauncherFix.setDataPath(saveDir.path)
@@ -181,22 +232,19 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
} }
private fun setupPostLibraryLoad(packageInfo: PackageInfo) { private fun setupPostLibraryLoad(packageInfo: PackageInfo) {
FMOD.init(this)
// call native functions after native libraries init // call native functions after native libraries init
JniToCpp.setupHSSAssets( JniToCpp.setupHSSAssets(
packageInfo.applicationInfo.sourceDir, packageInfo.applicationInfo!!.sourceDir,
Environment.getExternalStorageDirectory().absolutePath Environment.getExternalStorageDirectory().absolutePath
) )
Cocos2dxHelper.nativeSetApkPath(packageInfo.applicationInfo.sourceDir) Cocos2dxHelper.nativeSetApkPath(packageInfo.applicationInfo!!.sourceDir)
BaseRobTopActivity.setCurrentActivity(this) BaseRobTopActivity.setCurrentActivity(this)
registerReceiver()
} }
@SuppressLint("UnsafeDynamicallyLoadedCode") @SuppressLint("UnsafeDynamicallyLoadedCode")
private fun tryLoadLibrary(packageInfo: PackageInfo, libraryName: String) { private fun tryLoadLibrary(packageInfo: PackageInfo, libraryName: String) {
val nativeDir = getNativeLibraryDirectory(packageInfo.applicationInfo) val nativeDir = getNativeLibraryDirectory(packageInfo.applicationInfo!!)
val libraryPath = if (nativeDir.endsWith('/')) "${nativeDir}lib$libraryName.so" else "$nativeDir/lib$libraryName.so" val libraryPath = if (nativeDir.endsWith('/')) "${nativeDir}lib$libraryName.so" else "$nativeDir/lib$libraryName.so"
try { try {
@@ -343,15 +391,22 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
frameLayout.layoutParams = frameLayoutParams frameLayout.layoutParams = frameLayoutParams
if (displayMode == DisplayMode.LIMITED) { if (displayMode == DisplayMode.LIMITED) {
// despite not being perfectly 16:9, this is what Android calls "16:9" frameLayout.aspectRatio = mAspectRatio
frameLayout.aspectRatio = 1.86f }
if (mScreenZoomFit && mScreenZoom != 1.0f) {
frameLayout.fitZoom = true
}
if (mScreenZoom != 1.0f) {
frameLayout.zoom = mScreenZoom
} }
val editTextLayoutParams = ViewGroup.LayoutParams( val editTextLayoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT ViewGroup.LayoutParams.WRAP_CONTENT
) )
val editText = Cocos2dxEditText(this) val editText = AppCompatEditText(this)
editText.layoutParams = editTextLayoutParams editText.layoutParams = editTextLayoutParams
frameLayout.addView(editText) frameLayout.addView(editText)
@@ -360,23 +415,35 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
this.mGLSurfaceView = glSurfaceView this.mGLSurfaceView = glSurfaceView
frameLayout.addView(this.mGLSurfaceView) frameLayout.addView(this.mGLSurfaceView)
glSurfaceView.setEGLContextClientVersion(2) val showNotification = PreferenceUtils.get(this).getBoolean(PreferenceUtils.Key.ENABLE_REDESIGN)
glSurfaceView.setEGLConfigChooser(5, 6, 5, 0, 16, 8) if (showNotification) {
val hasCards = determineDisplayedCards(this)
if (hasCards.isNotEmpty()) {
val notificationView = ComposeView(this)
frameLayout.addView(notificationView)
if (isAndroidEmulator()) { notificationView.setContent {
glSurfaceView.setEGLConfigChooser(8, 8, 8, 8, 16, 0) LaunchNotification()
}
}
} }
glSurfaceView.setEGLContextClientVersion(2)
glSurfaceView.setEGLConfigChooser(EGLConfigChooser())
glSurfaceView.initView() glSurfaceView.initView()
val renderer = Cocos2dxRenderer(glSurfaceView) val renderer = Cocos2dxRenderer(glSurfaceView)
glSurfaceView.setCocos2dxRenderer(renderer) glSurfaceView.setCocos2dxRenderer(renderer)
if (forceRefreshRate) { renderer.setFrameRate = mForceRefreshRate
renderer.setFrameRate = true
val frameRate = mLimitedRefreshRate
if (frameRate != null) {
renderer.limitFrameRate(frameRate)
} }
editText.inputType = 145 editText.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
glSurfaceView.cocos2dxEditText = editText glSurfaceView.cocos2dxEditText = editText
return frameLayout return frameLayout
@@ -385,14 +452,37 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
private fun setupUIState() { private fun setupUIState() {
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE
val preferenceUtils = PreferenceUtils.get(this)
displayMode = DisplayMode.fromInt( displayMode = DisplayMode.fromInt(
PreferenceUtils.get(this).getInt(PreferenceUtils.Key.DISPLAY_MODE) preferenceUtils.getInt(PreferenceUtils.Key.DISPLAY_MODE)
) )
forceRefreshRate = PreferenceUtils.get(this).getBoolean(PreferenceUtils.Key.FORCE_HRR) mAspectRatio = ratioForPreference(preferenceUtils.getString(PreferenceUtils.Key.CUSTOM_ASPECT_RATIO) ?: "16_9")
mScreenZoom = preferenceUtils.getInt(PreferenceUtils.Key.SCREEN_ZOOM) / 100.0f
mScreenZoomFit = preferenceUtils.getBoolean(PreferenceUtils.Key.SCREEN_ZOOM_FIT)
val limitedRefreshRate = preferenceUtils.getInt(PreferenceUtils.Key.LIMIT_FRAME_RATE).takeIf { it != 0 }
mLimitedRefreshRate = limitedRefreshRate
mForceRefreshRate = true
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val chosenDisplay = display.supportedModes?.maxByOrNull { it.refreshRate }
if (chosenDisplay != null) {
var chosenRefreshRate = chosenDisplay.refreshRate
println("setupUIState: Forcing a refresh rate of $chosenRefreshRate (display ${chosenDisplay.modeId})")
window.attributes.preferredRefreshRate = chosenRefreshRate
} else {
println("setupUIState could not find a display to select refresh rate of...")
}
}
if (displayMode == DisplayMode.FULLSCREEN && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (displayMode == DisplayMode.FULLSCREEN && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window.attributes.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES window.attributes.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
GeodeUtils.handleSafeArea = true
} else {
GeodeUtils.handleSafeArea = false
} }
hideSystemUi() hideSystemUi()
@@ -420,19 +510,18 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
unregisterReceivers()
FMOD.close() FMOD.close()
} }
private fun resumeGame() { private fun resumeGame() {
mIsRunning = true mIsRunning = true
// Cocos2dxHelper.onResume() Cocos2dxHelper.onResume()
mGLSurfaceView?.onResume() mGLSurfaceView?.onResume()
} }
private fun pauseGame() { private fun pauseGame() {
mIsRunning = false mIsRunning = false
// Cocos2dxHelper.onPause() Cocos2dxHelper.onPause()
mGLSurfaceView?.onPause() mGLSurfaceView?.onPause()
} }
@@ -463,25 +552,6 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
} }
} }
private fun registerReceiver() {
unregisterReceivers()
try {
val filter = IntentFilter(Intent.ACTION_SCREEN_ON)
filter.addAction(Intent.ACTION_SCREEN_OFF)
filter.addAction(Intent.ACTION_USER_PRESENT)
mReceiver = BaseRobTopActivity.ReceiverScreen()
registerReceiver(mReceiver, filter)
} catch (_: Exception) {
}
}
private fun unregisterReceivers() {
if (mReceiver != null) {
unregisterReceiver(mReceiver)
mReceiver = null
}
}
override fun runOnGLThread(runnable: Runnable) { override fun runOnGLThread(runnable: Runnable) {
mGLSurfaceView?.queueEvent(runnable) mGLSurfaceView?.queueEvent(runnable)
} }
@@ -508,18 +578,6 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
TODO("Not yet implemented") TODO("Not yet implemented")
} }
private fun isAndroidEmulator(): Boolean {
Log.d(sTag, "model=" + Build.MODEL)
val product = Build.PRODUCT
Log.d(sTag, "product=$product")
var isEmulator = false
if (product != null) {
isEmulator = product == "sdk" || product.contains("_sdk") || product.contains("sdk_")
}
Log.d(sTag, "isEmulator=$isEmulator")
return isEmulator
}
override fun onCapabilityAdded(capability: String): Boolean { override fun onCapabilityAdded(capability: String): Boolean {
return when (capability) { return when (capability) {
GeodeUtils.CAPABILITY_EXTENDED_INPUT -> { GeodeUtils.CAPABILITY_EXTENDED_INPUT -> {
@@ -530,6 +588,10 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
mGLSurfaceView?.sendTimestampEvents = true mGLSurfaceView?.sendTimestampEvents = true
true true
} }
GeodeUtils.CAPABILITY_INTERNAL_CALLBACKS -> {
mGLSurfaceView?.sendInternalTimestampEvents = true
true
}
else -> false else -> false
} }
} }
@@ -543,7 +605,7 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
val modListing = try { val modListing = try {
assets.list(internalModBase) assets.list(internalModBase)
} catch (ioe: IOException) { } catch (_: IOException) {
emptyArray<String>() emptyArray<String>()
} }
@@ -565,4 +627,78 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
} }
} }
} }
class EGLConfigChooser : GLSurfaceView.EGLConfigChooser {
// this comes from EGL14, but is unavailable on EGL10
// also EGL14 is incompatible with EGL10. so whatever
private var EGL_OPENGL_ES2_BIT: Int = 0x04
private data class ConfigValues(
val redSize: Int,
val greenSize: Int,
val blueSize: Int,
val alphaSize: Int,
val depthSize: Int,
val stencilSize: Int,
)
private fun getAttribValue(egl: EGL10, display: EGLDisplay, config: EGLConfig, attrib: Int): Int {
val value = IntArray(1)
egl.eglGetConfigAttrib(display, config, attrib, value)
return value[0]
}
private fun testConfig(egl: EGL10, display: EGLDisplay, attrib: ConfigValues): EGLConfig? {
val attribList = intArrayOf(
EGL10.EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT,
EGL10.EGL_RED_SIZE, attrib.redSize,
EGL10.EGL_GREEN_SIZE, attrib.greenSize,
EGL10.EGL_BLUE_SIZE, attrib.blueSize,
EGL10.EGL_ALPHA_SIZE, attrib.alphaSize,
EGL10.EGL_DEPTH_SIZE, attrib.depthSize,
EGL10.EGL_STENCIL_SIZE, attrib.stencilSize,
EGL10.EGL_NONE
)
val configs = arrayOfNulls<EGLConfig>(1)
val numConfigs = IntArray(1)
val res = egl.eglChooseConfig(display, attribList, configs, configs.size, numConfigs)
if (res && numConfigs[0] > 0 && configs[0] != null) {
return configs[0]
}
return null
}
private fun printConfig(egl: EGL10, display: EGLDisplay, config: EGLConfig) {
val id = getAttribValue(egl, display, config, EGL10.EGL_CONFIG_ID)
val red = getAttribValue(egl, display, config, EGL10.EGL_RED_SIZE)
val green = getAttribValue(egl, display, config, EGL10.EGL_GREEN_SIZE)
val blue = getAttribValue(egl, display, config, EGL10.EGL_BLUE_SIZE)
val alpha = getAttribValue(egl, display, config, EGL10.EGL_ALPHA_SIZE)
val depth = getAttribValue(egl, display, config, EGL10.EGL_DEPTH_SIZE)
val stencil = getAttribValue(egl, display, config, EGL10.EGL_STENCIL_SIZE)
println("EGLConfig $id: (red = $red, green = $green, blue = $blue, alpha = $alpha, depth = $depth, stencil = $stencil)")
}
override fun chooseConfig(egl: EGL10, display: EGLDisplay): EGLConfig {
val configs = listOf(
ConfigValues(8, 8, 8, 8, 16, 8),
ConfigValues(8, 8, 8, 0, 16, 8),
ConfigValues(5, 6, 5, 0, 16, 8),
ConfigValues(0, 0, 0, 0, 0, 0),
)
return configs.firstNotNullOf {
val config = testConfig(egl, display, it)
if (config != null) {
printConfig(egl, display, config)
}
config
}
}
}
} }
@@ -12,4 +12,8 @@ object LauncherFix {
external fun setDataPath(dataPath: String) external fun setDataPath(dataPath: String)
external fun setOriginalDataPath(dataPath: String) external fun setOriginalDataPath(dataPath: String)
external fun performExceptionsRenaming()
external fun enableCustomSymbolList(symbolsPath: String)
} }
@@ -1,32 +1,17 @@
package com.geode.launcher package com.geode.launcher
import android.content.ActivityNotFoundException import android.app.NotificationChannel
import android.content.Context import android.app.NotificationManager
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.DocumentsContract
import android.text.format.Formatter.formatShortFileSize
import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.AbsoluteRoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
@@ -38,51 +23,28 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.geode.launcher.updater.ReleaseViewModel import com.geode.launcher.main.*
import com.geode.launcher.ui.theme.GeodeLauncherTheme import com.geode.launcher.ui.theme.GeodeLauncherTheme
import com.geode.launcher.ui.theme.LocalTheme import com.geode.launcher.ui.theme.LocalTheme
import com.geode.launcher.ui.theme.Theme import com.geode.launcher.ui.theme.Theme
import com.geode.launcher.ui.theme.Typography import com.geode.launcher.ui.theme.launcherTitleStyle
import com.geode.launcher.updater.ReleaseViewModel
import com.geode.launcher.utils.Constants import com.geode.launcher.utils.Constants
import com.geode.launcher.utils.GamePackageUtils
import com.geode.launcher.utils.LaunchUtils import com.geode.launcher.utils.LaunchUtils
import com.geode.launcher.utils.PreferenceUtils import com.geode.launcher.utils.PreferenceUtils
import com.geode.launcher.updater.ReleaseManager
import com.geode.launcher.utils.GamePackageUtils
import com.geode.launcher.utils.GeodeUtils
import com.geode.launcher.utils.useCountdownTimer
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import java.net.ConnectException
import java.net.UnknownHostException
data class LoadFailureInfo(
val title: LaunchUtils.LauncherError,
val description: String? = null,
val extendedMessage: String? = null
)
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() enableEdgeToEdge()
clearDownloadedApks(this)
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -102,14 +64,41 @@ class MainActivity : ComponentActivity() {
LoadFailureInfo(LaunchUtils.LauncherError.CRASHED) LoadFailureInfo(LaunchUtils.LauncherError.CRASHED)
} else { null } } else { null }
if (PreferenceUtils.get(this).getBoolean(PreferenceUtils.Key.DEVELOPER_SERVER)) {
val intent = Intent(this, DeveloperServerService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val name = getString(R.string.notification_channel_development_server)
val descriptionText = getString(R.string.notification_channel_development_server_description)
val importance = NotificationManager.IMPORTANCE_DEFAULT
val mChannel = NotificationChannel("geode_developer_server", name, importance)
mChannel.description = descriptionText
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(mChannel)
this.startForegroundService(intent)
}
}
val redirectToAlt = PreferenceUtils.get(this).getBoolean(PreferenceUtils.Key.ENABLE_REDESIGN)
if (redirectToAlt) {
val launchIntent = Intent(this, AltMainActivity::class.java)
launchIntent.putExtras(intent)
startActivity(launchIntent)
return
}
setContent { setContent {
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME) val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
val theme = Theme.fromInt(themeOption) val theme = Theme.fromInt(themeOption)
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND) val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
CompositionLocalProvider(LocalTheme provides theme) { CompositionLocalProvider(LocalTheme provides theme) {
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption) { GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background color = MaterialTheme.colorScheme.background
@@ -129,768 +118,6 @@ class MainActivity : ComponentActivity() {
} }
} }
@Composable
fun LauncherUpdateIndicator(modifier: Modifier = Modifier, openTo: String, onDismiss: () -> Unit) {
val uriHandler = LocalUriHandler.current
ElevatedCard(modifier) {
Column(
// buttons add enough padding already, lower to compensate
modifier = Modifier.padding(
top = 20.dp,
start = 12.dp,
end = 12.dp,
bottom = 8.dp
),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
stringResource(R.string.launcher_update_available),
modifier = Modifier.padding(horizontal = 10.dp)
)
Row(modifier = Modifier.align(Alignment.End)) {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.launcher_update_dismiss))
}
Spacer(Modifier.size(4.dp))
TextButton(onClick = { uriHandler.openUri(openTo) }) {
Text(stringResource(R.string.launcher_download))
}
}
}
}
}
@Composable
fun UpdateProgressIndicator(
message: String,
releaseViewModel: ReleaseViewModel,
modifier: Modifier = Modifier,
progress: (() -> Float)? = null
) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.Start,
modifier = modifier
) {
Text(message)
Spacer(modifier = Modifier.padding(4.dp))
if (progress == null) {
LinearProgressIndicator()
} else {
LinearProgressIndicator(progress = progress)
}
TextButton(
onClick = {
releaseViewModel.cancelUpdate()
},
modifier = Modifier.offset((-12).dp)
) {
Text(stringResource(R.string.release_fetch_button_cancel))
}
}
}
@Composable
fun UpdateMessageIndicator(
message: String,
releaseViewModel: ReleaseViewModel,
modifier: Modifier = Modifier,
allowRetry: Boolean = false
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
) {
Text(
message,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.padding(4.dp))
if (allowRetry) {
OutlinedButton(
onClick = {
releaseViewModel.runReleaseCheck(true)
},
) {
Icon(
Icons.Filled.Refresh,
contentDescription = stringResource(R.string.launcher_retry_icon_alt)
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.release_fetch_button_retry))
}
}
}
}
@Composable
fun UpdateWarning(inSafeMode: Boolean = false, onDismiss: () -> Unit) {
val context = LocalContext.current
val packageManager = context.packageManager
val gdVersionCode = remember { GamePackageUtils.getGameVersionCode(packageManager) }
val gdVersionString = remember { GamePackageUtils.getGameVersionString(packageManager) }
var lastDismissedVersion by PreferenceUtils.useLongPreference(
preferenceKey = PreferenceUtils.Key.DISMISSED_GJ_UPDATE
)
val canDismissRelease = gdVersionCode >= Constants.SUPPORTED_VERSION_CODE
val shouldDismiss = canDismissRelease && gdVersionCode == lastDismissedVersion
if (gdVersionCode != Constants.SUPPORTED_VERSION_CODE && !shouldDismiss) {
AlertDialog(
icon = {
Icon(
Icons.Filled.Warning,
contentDescription = stringResource(R.string.launcher_warning_icon_alt)
)
},
title = {
Text(stringResource(R.string.launcher_unsupported_version_title))
},
text = {
val message = if (gdVersionCode >= Constants.SUPPORTED_VERSION_CODE) {
R.string.launcher_unsupported_version_description
} else {
R.string.launcher_outdated_version_description
}
Text(stringResource(
message, gdVersionString, Constants.SUPPORTED_VERSION_STRING
))
},
confirmButton = {
TextButton(onClick = {
onDismiss()
lastDismissedVersion = gdVersionCode
onLaunch(context, inSafeMode)
}) {
Text(stringResource(R.string.message_box_accept))
}
},
dismissButton = {
TextButton(onClick = { onDismiss() }) {
Text(stringResource(R.string.message_box_cancel))
}
},
onDismissRequest = { onDismiss() }
)
} else {
LaunchedEffect(gdVersionCode) {
onLaunch(context, inSafeMode)
}
}
}
@Composable
fun SafeModeDialog(onDismiss: () -> Unit, onLaunch: () -> Unit) {
AlertDialog(
icon = {
Icon(
Icons.Filled.Warning,
contentDescription = stringResource(R.string.launcher_warning_icon_alt)
)
},
title = { Text(stringResource(R.string.safe_mode_enable_title)) },
text = { Text(stringResource(R.string.safe_mode_enable_description)) },
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.message_box_cancel))
}
},
confirmButton = {
TextButton(onClick = onLaunch) {
Text(stringResource(R.string.message_box_continue))
}
},
onDismissRequest = onDismiss
)
}
@Composable
fun UpdateCard(releaseViewModel: ReleaseViewModel, modifier: Modifier = Modifier) {
val context = LocalContext.current
val releaseState by releaseViewModel.uiState.collectAsState()
when (val state = releaseState) {
is ReleaseViewModel.ReleaseUIState.Failure -> {
val message = when (state.exception) {
is UnknownHostException, is ConnectException ->
stringResource(R.string.release_fetch_no_internet)
is ReleaseManager.UpdateException -> {
when (state.exception.reason) {
ReleaseManager.UpdateException.Reason.EXTERNAL_FILE_IN_USE -> stringResource(
R.string.release_fetch_manual_check_required
)
else -> null
}
}
else -> null
}
UpdateMessageIndicator(
stringResource(
R.string.release_fetch_failed,
message ?: stringResource(R.string.release_fetch_try_later)
),
modifier = modifier,
allowRetry = true,
releaseViewModel = releaseViewModel
)
}
is ReleaseViewModel.ReleaseUIState.InDownload -> {
val downloaded = remember(state.downloaded) {
formatShortFileSize(context, state.downloaded)
}
val outOf = remember(state.outOf) {
formatShortFileSize(context, state.outOf)
}
UpdateProgressIndicator(
stringResource(
R.string.release_fetch_downloading,
downloaded,
outOf
),
modifier = modifier,
releaseViewModel = releaseViewModel,
progress = {
val progress = state.downloaded / state.outOf.toDouble()
progress.toFloat()
}
)
}
is ReleaseViewModel.ReleaseUIState.InUpdateCheck -> {
UpdateProgressIndicator(
stringResource(R.string.release_fetch_in_progress),
modifier = modifier,
releaseViewModel = releaseViewModel
)
}
is ReleaseViewModel.ReleaseUIState.Finished -> {
if (state.hasUpdated) {
UpdateMessageIndicator(
stringResource(R.string.release_fetch_success),
modifier = modifier,
releaseViewModel = releaseViewModel
)
}
}
is ReleaseViewModel.ReleaseUIState.Cancelled -> {
if (state.isCancelling) {
UpdateProgressIndicator(
stringResource(R.string.release_fetch_cancelling),
modifier = modifier,
releaseViewModel = releaseViewModel
)
} else {
UpdateMessageIndicator(
stringResource(R.string.release_fetch_cancelled),
modifier = modifier,
allowRetry = true,
releaseViewModel = releaseViewModel
)
}
}
}
val nextUpdate by releaseViewModel.nextLauncherUpdate.collectAsState()
val nextUpdateValue = nextUpdate
if (!releaseViewModel.isInUpdate && nextUpdateValue != null) {
val updateUrl = nextUpdateValue.getLauncherDownload()?.browserDownloadUrl
?: nextUpdateValue.htmlUrl
LauncherUpdateIndicator(
modifier = modifier,
openTo = updateUrl,
onDismiss = {
releaseViewModel.dismissLauncherUpdate()
}
)
}
}
@Composable
fun ErrorInfoBody(failureReason: LaunchUtils.LauncherError, modifier: Modifier = Modifier) {
val headline = when (failureReason) {
LaunchUtils.LauncherError.LINKER_FAILED -> stringResource(R.string.load_failed_link_error_description)
LaunchUtils.LauncherError.CRASHED -> stringResource(R.string.load_failed_crashed_description)
else -> stringResource(R.string.load_failed_generic_error_description)
}
val recommendations = when (failureReason) {
LaunchUtils.LauncherError.LINKER_FAILED -> listOf(
stringResource(R.string.load_failed_recommendation_reinstall),
stringResource(R.string.load_failed_recommendation_update),
)
LaunchUtils.LauncherError.CRASHED -> listOf(
stringResource(R.string.load_failed_recommendation_safe_mode),
stringResource(R.string.load_failed_recommendation_report)
)
else -> listOf(
stringResource(R.string.load_failed_recommendation_reinstall),
stringResource(R.string.load_failed_recommendation_update),
stringResource(R.string.load_failed_recommendation_report)
)
}
Column(modifier = modifier) {
Text(headline)
recommendations.forEach { r ->
Text("\u2022\u00A0\u00A0$r")
}
}
}
@Composable
fun ErrorInfoTitle(failureReason: LaunchUtils.LauncherError) {
val message = when (failureReason) {
LaunchUtils.LauncherError.LINKER_NEEDS_64BIT,
LaunchUtils.LauncherError.LINKER_NEEDS_32BIT,
LaunchUtils.LauncherError.LINKER_FAILED -> stringResource(R.string.load_failed_link_error)
LaunchUtils.LauncherError.GENERIC -> stringResource(R.string.load_failed_generic_error)
LaunchUtils.LauncherError.CRASHED -> stringResource(R.string.load_failed_crashed)
}
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painterResource(R.drawable.icon_error),
contentDescription = stringResource(R.string.launcher_error_icon_alt),
modifier = Modifier.size(28.dp)
)
Text(message, style = Typography.headlineMedium)
}
}
fun onShareCrash(context: Context) {
val lastCrash = LaunchUtils.getLastCrash(context)
if (lastCrash == null) {
Toast.makeText(context, R.string.launcher_error_export_missing, Toast.LENGTH_SHORT).show()
return
}
val baseDirectory = LaunchUtils.getBaseDirectory(context)
val providerLocation = lastCrash.toRelativeString(baseDirectory)
val documentsPath = "${UserDirectoryProvider.ROOT}${providerLocation}"
val uri = DocumentsContract.buildDocumentUri("${context.packageName}.user", documentsPath)
val shareIntent = Intent(Intent.ACTION_SEND).apply {
putExtra(Intent.EXTRA_STREAM, uri)
type = "application/octet-stream"
}
try {
context.startActivity(shareIntent)
} catch (_: ActivityNotFoundException) {
Toast.makeText(context, R.string.no_activity_found, Toast.LENGTH_SHORT).show()
}
}
fun onShowLogs(context: Context) {
val launchIntent = Intent(context, ApplicationLogsActivity::class.java)
context.startActivity(launchIntent)
}
@Composable
fun ErrorInfoDescription(description: String, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
Text(
stringResource(R.string.launcher_error_details),
style = Typography.headlineSmall
)
Spacer(Modifier.size(8.dp))
Text(
description,
fontFamily = FontFamily.Monospace,
style = Typography.bodyMedium
)
Spacer(Modifier.size(4.dp))
Text(
stringResource(R.string.launcher_error_device_info, Build.MODEL, Build.VERSION.RELEASE),
style = Typography.labelMedium
)
}
}
@Composable
@OptIn(ExperimentalLayoutApi::class)
fun ErrorInfoActions(extraDetails: String?, modifier: Modifier = Modifier) {
val clipboardManager = LocalClipboardManager.current
val context = LocalContext.current
FlowRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(
12.dp, alignment = Alignment.CenterHorizontally
)
) {
if (extraDetails != null) {
Button(onClick = { onShowLogs(context) }) {
Icon(
painterResource(R.drawable.icon_description),
contentDescription = null
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_error_view_logs))
}
FilledTonalButton(onClick = {
clipboardManager.setText(AnnotatedString(extraDetails))
}) {
Icon(
painterResource(R.drawable.icon_content_copy),
contentDescription = null
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_error_copy))
}
} else {
Button(onClick = { onShareCrash(context) }) {
Icon(
Icons.Filled.Share,
contentDescription = null
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_error_export_crash))
}
FilledTonalButton(onClick = { onShowLogs(context) }) {
Icon(
painterResource(R.drawable.icon_description),
contentDescription = null
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_error_view_logs))
}
}
}
}
fun onDownloadGame(context: Context) {
val appUrl = "https://play.google.com/store/apps/details?id=${Constants.PACKAGE_NAME}"
try {
val intent = Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(appUrl)
setPackage("com.android.vending")
}
context.startActivity(intent)
} catch (_: ActivityNotFoundException) {
Toast.makeText(context, R.string.no_activity_found, Toast.LENGTH_SHORT).show()
}
}
@Composable
fun GooglePlayBadge(modifier: Modifier = Modifier) {
val context = LocalContext.current
Image(
painter = painterResource(id = R.drawable.google_play_badge),
contentDescription = stringResource(R.string.launcher_download_game),
modifier = modifier
.width(196.dp)
.clip(AbsoluteRoundedCornerShape(4.dp))
.clickable { onDownloadGame(context) }
)
}
@Composable
fun DownloadRecommendation(needsUniversal: Boolean, modifier: Modifier = Modifier) {
val uriHandler = LocalUriHandler.current
val context = LocalContext.current
val version = BuildConfig.VERSION_NAME
val showDownload = remember {
!GamePackageUtils.isGameInstalled(context.packageManager) ||
!GamePackageUtils.identifyGameLegitimacy(context.packageManager)
}
val downloadBase = "https://github.com/geode-sdk/android-launcher/releases/download/$version"
val legacyDownloadUrl = "$downloadBase/geode-launcher-v$version-android32.apk"
val universalDownloadUrl = "$downloadBase/geode-launcher-v$version.apk"
val downloadUrl = if (needsUniversal)
universalDownloadUrl else legacyDownloadUrl
val downloadText = if (needsUniversal)
stringResource(R.string.launcher_download_universal)
else
stringResource(R.string.launcher_download_32bit)
val promptTitle = stringResource(R.string.load_failed_abi_error)
val reinstallText = stringResource(R.string.load_failed_recommendation_reinstall)
val recommendationText = if (needsUniversal)
stringResource(R.string.load_failed_recommendation_switch_64bit_abi)
else
stringResource(R.string.load_failed_recommendation_switch_32bit_abi)
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(
12.dp, alignment = Alignment.CenterVertically
)
) {
Text(promptTitle)
Text("\u2022\u00A0\u00A0$reinstallText")
if (showDownload) {
GooglePlayBadge()
}
Text("\u2022\u00A0\u00A0$recommendationText")
ClickableText(
text = AnnotatedString(downloadText),
onClick = {
uriHandler.openUri(downloadUrl)
},
style = TextStyle.Default.copy(
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline
),
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ErrorInfoSheet(failureInfo: LoadFailureInfo, onDismiss: () -> Unit) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(onDismissRequest = { onDismiss() }, sheetState = sheetState) {
// second container created for scroll (before padding is added)
Box(Modifier.verticalScroll(rememberScrollState())) {
Column(modifier = Modifier
.padding(horizontal = 24.dp)
) {
ErrorInfoTitle(failureInfo.title)
Spacer(Modifier.size(16.dp))
if (failureInfo.title.isAbiFailure()) {
DownloadRecommendation(
needsUniversal = failureInfo.title == LaunchUtils.LauncherError.LINKER_NEEDS_64BIT,
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 16.dp)
)
} else {
ErrorInfoBody(failureInfo.title)
if (failureInfo.description != null) {
ErrorInfoDescription(
failureInfo.description,
modifier = Modifier.padding(vertical = 8.dp)
)
}
ErrorInfoActions(
extraDetails = failureInfo.extendedMessage ?: failureInfo.description,
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 16.dp)
)
}
}
}
}
}
fun onLaunch(context: Context, safeMode: Boolean = false) {
if (safeMode) {
GeodeUtils.setAdditionalLaunchArguments(GeodeUtils.ARGUMENT_SAFE_MODE)
} else {
GeodeUtils.clearLaunchArguments()
}
val launchIntent = Intent(context, GeometryDashActivity::class.java)
launchIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
context.startActivity(launchIntent)
}
fun onSettings(context: Context) {
val launchIntent = Intent(context, SettingsActivity::class.java)
context.startActivity(launchIntent)
}
suspend fun showFailureSnackbar(
context: Context,
loadFailureInfo: LoadFailureInfo,
snackbarHostState: SnackbarHostState,
onActionPerformed: () -> Unit
) {
val message = if (loadFailureInfo.title == LaunchUtils.LauncherError.CRASHED) {
context.getString(R.string.launcher_crashed)
} else {
context.getString(R.string.launcher_failed_to_load)
}
val res = snackbarHostState.showSnackbar(
message = message,
actionLabel = context.getString(R.string.launcher_error_more),
duration = SnackbarDuration.Indefinite,
)
when (res) {
SnackbarResult.ActionPerformed -> {
onActionPerformed()
}
SnackbarResult.Dismissed -> {}
}
}
@Composable
fun PlayButton(
stopAutomaticLaunch: Boolean,
blockLaunch: Boolean,
onPlayGame: (Boolean) -> Unit
) {
val context = LocalContext.current
var showSafeModeDialog by remember { mutableStateOf(false) }
var launchInSafeMode by remember { mutableStateOf(false) }
val shouldAutomaticallyLaunch by PreferenceUtils.useBooleanPreference(
preferenceKey = PreferenceUtils.Key.LOAD_AUTOMATICALLY
)
if (shouldAutomaticallyLaunch && !stopAutomaticLaunch && !showSafeModeDialog) {
val countdownTimer = useCountdownTimer(
time = 3000,
onCountdownFinish = { onPlayGame(launchInSafeMode) }
)
if (countdownTimer != 0L) {
Text(
pluralStringResource(
R.plurals.automatically_load_countdown,
countdownTimer.toInt(),
countdownTimer
),
style = Typography.bodyMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
Spacer(Modifier.size(12.dp))
}
}
// compose apis don't provide a good way of adding long press to a button
val interactionSource = remember { MutableInteractionSource() }
val viewConfiguration = LocalViewConfiguration.current
val haptics = LocalHapticFeedback.current
LaunchedEffect(interactionSource) {
interactionSource.interactions.collectLatest { interaction ->
when (interaction) {
is PressInteraction.Press -> {
launchInSafeMode = false
delay(viewConfiguration.longPressTimeoutMillis)
// perform a second delay to make the action more obvious
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
delay(viewConfiguration.longPressTimeoutMillis)
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
showSafeModeDialog = true
}
is PressInteraction.Release -> {
if (!showSafeModeDialog) {
onPlayGame(launchInSafeMode)
}
}
}
}
}
Row {
Button(
onClick = { },
enabled = !blockLaunch,
interactionSource = interactionSource
) {
Icon(
Icons.Filled.PlayArrow,
contentDescription = context.getString(R.string.launcher_launch_icon_alt)
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(context.getString(R.string.launcher_launch))
}
Spacer(Modifier.size(2.dp))
IconButton(onClick = { onSettings(context) }) {
Icon(
Icons.Filled.Settings,
contentDescription = context.getString(R.string.launcher_settings_icon_alt)
)
}
}
if (showSafeModeDialog) {
SafeModeDialog(
onDismiss = {
launchInSafeMode = false
showSafeModeDialog = false
},
onLaunch = {
launchInSafeMode = true
onPlayGame(true)
showSafeModeDialog = false
}
)
}
}
@Composable
fun LaunchBlockedLabel(text: String) {
val context = LocalContext.current
val showDownload = remember {
GamePackageUtils.isGameInstalled(context.packageManager) &&
!GamePackageUtils.identifyGameLegitimacy(context.packageManager)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(text, textAlign = TextAlign.Center, modifier = Modifier.padding(12.dp))
if (showDownload) {
GooglePlayBadge()
}
}
}
@Composable @Composable
fun MainScreen( fun MainScreen(
gdInstalled: Boolean = true, gdInstalled: Boolean = true,
@@ -904,7 +131,7 @@ fun MainScreen(
val autoUpdateState by releaseViewModel.uiState.collectAsState() val autoUpdateState by releaseViewModel.uiState.collectAsState()
val geodeJustInstalled = (autoUpdateState as? ReleaseViewModel.ReleaseUIState.Finished) val geodeJustInstalled = (autoUpdateState as? ReleaseViewModel.ReleaseUIState.Finished)
?.hasUpdated ?: false ?.hasUpdated == true
val geodeInstalled = geodePreinstalled || geodeJustInstalled val geodeInstalled = geodePreinstalled || geodeJustInstalled
var beginLaunch by remember { mutableStateOf(false) } var beginLaunch by remember { mutableStateOf(false) }
@@ -951,12 +178,12 @@ fun MainScreen(
) { ) {
Image( Image(
painter = painterResource(id = R.drawable.geode_logo), painter = painterResource(id = R.drawable.geode_logo),
contentDescription = context.getString(R.string.launcher_logo_alt), contentDescription = stringResource(R.string.launcher_logo_alt),
modifier = Modifier.size(136.dp, 136.dp) modifier = Modifier.size(100.dp, 100.dp)
) )
Text( Text(
context.getString(R.string.launcher_title), stringResource(R.string.launcher_title),
fontSize = 32.sp, style = launcherTitleStyle,
modifier = Modifier.padding(12.dp) modifier = Modifier.padding(12.dp)
) )
@@ -966,7 +193,7 @@ fun MainScreen(
GamePackageUtils.getGameVersionCode(context.packageManager) GamePackageUtils.getGameVersionCode(context.packageManager)
} }
if (gdVersion < Constants.SUPPORTED_VERSION_CODE) { if (gdVersion < Constants.SUPPORTED_VERSION_CODE_MIN) {
val versionName = remember { val versionName = remember {
GamePackageUtils.getUnifiedVersionName(context.packageManager) GamePackageUtils.getUnifiedVersionName(context.packageManager)
} }
@@ -982,11 +209,15 @@ fun MainScreen(
beginLaunch = true beginLaunch = true
}, },
) )
if (gdVersion < Constants.SUPPORTED_VERSION_CODE_MIN_WARNING) {
UnsupportedVersionWarning()
}
} }
} }
gdInstalled -> { gdInstalled -> {
Text( Text(
context.getString(R.string.geode_download_title), stringResource(R.string.geode_download_title),
modifier = Modifier.padding(12.dp) modifier = Modifier.padding(12.dp)
) )
@@ -997,16 +228,16 @@ fun MainScreen(
) { ) {
Icon( Icon(
painterResource(R.drawable.icon_download), painterResource(R.drawable.icon_download),
contentDescription = context.getString(R.string.launcher_download_icon_alt) contentDescription = stringResource(R.string.launcher_download_icon_alt)
) )
Spacer(Modifier.size(ButtonDefaults.IconSpacing)) Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(context.getString(R.string.launcher_download)) Text(stringResource(R.string.launcher_download))
} }
Spacer(Modifier.size(2.dp)) Spacer(Modifier.size(2.dp))
IconButton(onClick = { onSettings(context) }) { IconButton(onClick = { onSettings(context) }) {
Icon( Icon(
Icons.Filled.Settings, painterResource(R.drawable.icon_settings),
contentDescription = context.getString(R.string.launcher_settings_icon_alt) contentDescription = stringResource(R.string.launcher_settings_icon_alt)
) )
} }
} }
@@ -1,406 +0,0 @@
package com.geode.launcher
import android.app.UiModeManager
import android.content.ActivityNotFoundException
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.provider.DocumentsContract
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedDispatcher
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.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.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.geode.launcher.updater.ReleaseViewModel
import com.geode.launcher.ui.theme.GeodeLauncherTheme
import com.geode.launcher.ui.theme.LocalTheme
import com.geode.launcher.ui.theme.Theme
import com.geode.launcher.ui.theme.Typography
import com.geode.launcher.utils.LaunchUtils
import com.geode.launcher.utils.PreferenceUtils
import java.net.ConnectException
import java.net.UnknownHostException
class SettingsActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
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)
CompositionLocalProvider(LocalTheme provides theme) {
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
SettingsScreen(
onBackPressedDispatcher = onBackPressedDispatcher
)
}
}
}
}
}
}
fun onOpenFolder(context: Context) {
val file = LaunchUtils.getBaseDirectory(context)
val clipboardManager =
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboardManager.setPrimaryClip(
ClipData.newPlainText(
context.getString(R.string.export_folder_tag),
file.path
)
)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
Toast.makeText(
context,
context.getString(R.string.text_copied),
Toast.LENGTH_SHORT
).show()
}
}
fun onOpenFileManager(context: Context) {
// future thoughts: Intent.ACTION_VIEW may not work on some devices
// the "correct" solution is to go through a list of possible intents/applications
// don't feel like writing that behavior. this is good enough
val intent = Intent(Intent.ACTION_VIEW).apply {
data = DocumentsContract.buildRootUri(
"${context.packageName}.user",
UserDirectoryProvider.ROOT
)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
Intent.FLAG_GRANT_PREFIX_URI_PERMISSION or
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
)
}
try {
context.startActivity(intent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(
context,
context.getText(R.string.no_activity_found), Toast.LENGTH_SHORT
).show()
}
}
@Composable
fun UpdateIndicator(
snackbarHostState: SnackbarHostState,
updateStatus: ReleaseViewModel.ReleaseUIState
) {
val context = LocalContext.current
var enablePopup by remember { mutableStateOf(false) }
when (updateStatus) {
is ReleaseViewModel.ReleaseUIState.InUpdateCheck -> {
enablePopup = true
CircularProgressIndicator()
}
is ReleaseViewModel.ReleaseUIState.InDownload -> {
// is this the ideal design? idk
enablePopup = true
val progress = updateStatus.downloaded / updateStatus.outOf.toDouble()
CircularProgressIndicator(
progress = { progress.toFloat() },
)
}
else -> {}
}
LaunchedEffect(updateStatus) {
// only show popup if some progress was shown
if (!enablePopup) {
return@LaunchedEffect
}
when (updateStatus) {
is ReleaseViewModel.ReleaseUIState.Failure -> {
val message = when (updateStatus.exception) {
is UnknownHostException, is ConnectException ->
context.getString(R.string.release_fetch_no_internet)
else -> context.getString(R.string.preference_check_for_updates_failed)
}
snackbarHostState.showSnackbar(message)
}
is ReleaseViewModel.ReleaseUIState.Finished -> {
if (updateStatus.hasUpdated) {
snackbarHostState.showSnackbar(
context.getString(R.string.preference_check_for_updates_success)
)
} else {
snackbarHostState.showSnackbar(
context.getString(R.string.preference_check_for_updates_none_found)
)
}
}
else -> {}
}
}
}
@Composable
fun themeToKey(theme: Int): String {
return when (theme) {
1 -> stringResource(R.string.preference_theme_light)
2 -> stringResource(R.string.preference_theme_dark)
else -> stringResource(R.string.preference_theme_default)
}
}
@Composable
fun displayOptionToKey(option: Int): String {
return when (option) {
1 -> stringResource(R.string.preference_display_mode_legacy)
2 -> stringResource(R.string.preference_display_mode_fullscreen)
else -> stringResource(R.string.preference_display_mode_default)
}
}
fun updateTheme(context: Context, theme: Int) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
uiModeManager.setApplicationNightMode(
when (theme) {
1 -> UiModeManager.MODE_NIGHT_NO
2 -> UiModeManager.MODE_NIGHT_YES
else -> UiModeManager.MODE_NIGHT_CUSTOM
}
)
} else {
AppCompatDelegate.setDefaultNightMode(
when (theme) {
1 -> AppCompatDelegate.MODE_NIGHT_NO
2 -> AppCompatDelegate.MODE_NIGHT_YES
else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
)
}
}
fun onOpenLogs(context: Context) {
val launchIntent = Intent(context, ApplicationLogsActivity::class.java)
context.startActivity(launchIntent)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onBackPressedDispatcher: OnBackPressedDispatcher?,
releaseViewModel: ReleaseViewModel = viewModel(factory = ReleaseViewModel.Factory)
) {
val context = LocalContext.current
val currentRelease by PreferenceUtils.useStringPreference(PreferenceUtils.Key.CURRENT_VERSION_TAG)
val updateStatus by releaseViewModel.uiState.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
var showUpdateInProgress by remember { mutableStateOf(false) }
if (showUpdateInProgress) {
LaunchedEffect(snackbarHostState) {
snackbarHostState.showSnackbar(
context.getString(R.string.preference_check_for_updates_already_updating)
)
showUpdateInProgress = false
}
}
// fix theme transition by giving it the exact same animation as the top bar
val containerColor by animateColorAsState(
targetValue = MaterialTheme.colorScheme.background,
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
label = "background color"
)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
containerColor = containerColor,
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
},
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = context.getString(R.string.back_icon_alt)
)
}
},
title = {
Text(
context.getString(R.string.title_activity_settings),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
scrollBehavior = scrollBehavior,
)
},
content = { innerPadding ->
Column(
Modifier
.padding(innerPadding)
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
OptionsGroup(context.getString(R.string.preference_category_testing)) {
SettingsSelectCard(
title = stringResource(R.string.preference_theme_name),
dialogTitle = stringResource(R.string.preference_theme_select),
maxVal = 2,
preferenceKey = PreferenceUtils.Key.THEME,
toLabel = { themeToKey(it) },
extraSelectBehavior = { updateTheme(context, it) }
)
SettingsCard(
title = stringResource(R.string.preference_black_background_name),
preferenceKey = PreferenceUtils.Key.BLACK_BACKGROUND
)
SettingsCard(
title = context.getString(R.string.preference_load_automatically_name),
description = context.getString(R.string.preference_load_automatically_description),
preferenceKey = PreferenceUtils.Key.LOAD_AUTOMATICALLY,
)
SettingsSelectCard(
title = stringResource(R.string.preference_display_mode_name),
dialogTitle = stringResource(R.string.preference_display_mode_select),
// necessary api doesn't exist on older versions of android
maxVal = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) 2 else 1,
preferenceKey = PreferenceUtils.Key.DISPLAY_MODE,
toLabel = { displayOptionToKey(it) }
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
SettingsCard(
title = context.getString(R.string.preference_force_hrr),
preferenceKey = PreferenceUtils.Key.FORCE_HRR,
)
}
OptionsButton(
title = stringResource(R.string.preferences_open_file_manager),
onClick = { onOpenFileManager(context) }
)
}
OptionsGroup(context.getString(R.string.preference_category_updater)) {
SettingsCard(
title = context.getString(R.string.preference_update_automatically_name),
preferenceKey = PreferenceUtils.Key.UPDATE_AUTOMATICALLY,
)
SettingsCard(
title = context.getString(R.string.preference_release_channel_name),
preferenceKey = PreferenceUtils.Key.RELEASE_CHANNEL,
)
OptionsCard(
title = {
OptionsTitle(
title = stringResource(R.string.preference_check_for_updates_button),
description = stringResource(
R.string.preference_check_for_updates_description,
currentRelease ?: "unknown"
)
)
},
modifier = Modifier
.clickable(
onClick = {
if (releaseViewModel.isInUpdate) {
showUpdateInProgress = true
} else {
releaseViewModel.runReleaseCheck(true)
}
},
role = Role.Button
)
) {
UpdateIndicator(snackbarHostState, updateStatus)
}
}
OptionsGroup(stringResource(R.string.preference_category_developer)) {
SettingsStringCard(
title = stringResource(R.string.preference_launch_arguments_name),
dialogTitle = stringResource(R.string.preference_launch_arguments_set_title),
preferenceKey = PreferenceUtils.Key.LAUNCH_ARGUMENTS,
filterInput = { it.filter { c ->
// if only there was a better way to define this!
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890!@#$%^&*(){}<>[]?:;'\"~`-_+=\\| ".contains(c)
}}
)
OptionsButton(
title = context.getString(R.string.preferences_copy_external_button),
description = LaunchUtils.getBaseDirectory(context).path,
onClick = { onOpenFolder(context) }
)
OptionsButton(
title = stringResource(R.string.preferences_view_logs),
onClick = { onOpenLogs(context) }
)
}
Text(
stringResource(R.string.preference_launcher_version, BuildConfig.VERSION_NAME),
style = Typography.labelSmall,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
)
}
}
)
}
@Preview(showSystemUi = true)
@Composable
fun DefaultPreview() {
GeodeLauncherTheme {
SettingsScreen(null)
}
}
@@ -1,366 +0,0 @@
package com.geode.launcher
import android.content.Context
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.geode.launcher.ui.theme.GeodeLauncherTheme
import com.geode.launcher.ui.theme.Typography
import com.geode.launcher.utils.PreferenceUtils
fun toggleSetting(context: Context, preferenceKey: PreferenceUtils.Key): Boolean {
val preferences = PreferenceUtils.get(context)
return preferences.toggleBoolean(preferenceKey)
}
fun getSetting(context: Context, preferenceKey: PreferenceUtils.Key): Boolean {
val preferences = PreferenceUtils.get(context)
return preferences.getBoolean(preferenceKey)
}
@Composable
fun OptionsGroup(title: String, content: @Composable () -> Unit) {
Column {
Text(
title,
style = Typography.titleMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
content()
}
}
@Composable
fun SettingsSelectCard(
title: String,
dialogTitle: String,
maxVal: Int,
preferenceKey: PreferenceUtils.Key,
toLabel: @Composable (Int) -> String,
extraSelectBehavior: ((Int) -> Unit)? = null
) {
val preferenceValue by PreferenceUtils.useIntPreference(preferenceKey)
var showDialog by remember { mutableStateOf(false) }
OptionsCard(
title = { OptionsTitle(title = title) },
modifier = Modifier
.clickable(
onClick = {
showDialog = true
},
role = Role.Button
)
) {
Text(toLabel(preferenceValue))
}
if (showDialog) {
val context = LocalContext.current
SelectDialog(
title = dialogTitle,
onDismissRequest = {
showDialog = false
},
onSelect = { selected ->
showDialog = false
PreferenceUtils.get(context)
.setInt(preferenceKey, selected)
extraSelectBehavior?.invoke(selected)
},
initialValue = preferenceValue,
toLabel = toLabel,
optionsCount = 0..maxVal
)
}
}
@Composable
fun SettingsStringCard(
title: String,
dialogTitle: String,
preferenceKey: PreferenceUtils.Key,
filterInput: ((String) -> String)? = null
) {
var preferenceValue by PreferenceUtils.useStringPreference(preferenceKey)
var showDialog by remember { mutableStateOf(false) }
OptionsCard(
title = {
OptionsTitle(title = title, description = preferenceValue)
},
modifier = Modifier
.clickable(
onClick = {
showDialog = true
},
role = Role.Button
)
) { }
if (showDialog) {
StringDialog(
title = dialogTitle,
onDismissRequest = { showDialog = false },
onSelect = {
preferenceValue = it
showDialog = false
},
initialValue = preferenceValue ?: "",
filterInput = filterInput
)
}
}
@Composable
fun StringDialog(
title: String,
onDismissRequest: () -> Unit,
onSelect: (String) -> Unit,
initialValue: String,
filterInput: ((String) -> String)? = null
) {
var enteredValue by remember { mutableStateOf(initialValue) }
AlertDialog(
onDismissRequest = { onDismissRequest() },
title = {
Text(title)
},
text = {
OutlinedTextField(
value = enteredValue,
onValueChange = {
enteredValue = filterInput?.invoke(it) ?: it
},
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Ascii,
autoCorrect = false
),
trailingIcon = {
if (enteredValue.isNotEmpty()) {
IconButton(onClick = { enteredValue = "" }) {
Icon(
Icons.Filled.Clear,
contentDescription = stringResource(R.string.preference_text_clear)
)
}
}
}
)
},
confirmButton = {
TextButton(onClick = { onSelect(enteredValue) }) {
Text(stringResource(R.string.message_box_accept))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.message_box_cancel))
}
},
)
}
@Composable
fun <T> SelectDialog(
title: String,
onDismissRequest: () -> Unit,
onSelect: (T) -> Unit,
initialValue: T,
toLabel: @Composable (T) -> String,
optionsCount: Iterable<T>,
) {
var selectedValue by remember { mutableStateOf(initialValue) }
Dialog(onDismissRequest = onDismissRequest) {
Card(
shape = RoundedCornerShape(16.dp)
) {
// styling a dialog is actually a little hard if you're doing what i'm doing
// maybe there's a better way to make these padding values...
Column {
Text(
title,
style = Typography.titleLarge,
modifier = Modifier.padding(
start = 28.dp,
top = 24.dp,
bottom = 12.dp
)
)
// do not give the row or column padding!! it messes up the selection effect
optionsCount.forEach { id ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(
onClick = { selectedValue = id },
role = Role.RadioButton
)
.padding(horizontal = 12.dp)
) {
RadioButton(
selected = selectedValue == id,
onClick = { selectedValue = id }
)
Text(
toLabel(id),
style = Typography.bodyMedium
)
}
}
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier
.fillMaxWidth()
.padding(
bottom = 16.dp,
end = 16.dp,
top = 4.dp
)
) {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.message_box_cancel))
}
TextButton(onClick = { onSelect(selectedValue) }) {
Text(stringResource(R.string.message_box_accept))
}
}
}
}
}
}
@Composable
fun OptionsButton(title: String, description: String? = null, onClick: () -> Unit) {
OptionsCard(
title = {
OptionsTitle(
title = title,
description = description
)
},
modifier = Modifier
.clickable(onClick = onClick, role = Role.Button)
) { }
}
@Composable
fun SettingsCard(title: String, description: String? = null, preferenceKey: PreferenceUtils.Key) {
val context = LocalContext.current
val settingEnabled = remember {
mutableStateOf(getSetting(context, preferenceKey))
}
OptionsCard(
title = {
OptionsTitle(
Modifier.fillMaxWidth(0.75f),
title = title,
description = description
)
},
modifier = Modifier.toggleable(
value = settingEnabled.value,
onValueChange = { settingEnabled.value = toggleSetting(context, preferenceKey) },
role = Role.Switch,
)
) {
Switch(checked = settingEnabled.value, onCheckedChange = null)
}
}
@Composable
fun OptionsTitle(modifier: Modifier = Modifier, title: String, description: String? = null) {
Column(
modifier,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(title)
if (!description.isNullOrEmpty()) {
Text(
description,
style = Typography.labelMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
}
@Composable
fun OptionsCard(modifier: Modifier = Modifier, title: @Composable () -> Unit, content: @Composable () -> Unit) {
Row(
modifier
.fillMaxWidth()
.height(64.dp)
.padding(horizontal = 16.dp),
Arrangement.SpaceBetween,
Alignment.CenterVertically,
) {
title()
content()
}
}
@Preview(showBackground = true)
@Composable
fun OptionsCardPreview() {
GeodeLauncherTheme {
OptionsGroup(title = "Preview Group") {
SettingsCard(
title = "Load files from /test",
description = "Very long testing description goes here. It is incredibly long, it should wrap onto a new line.",
preferenceKey = PreferenceUtils.Key.BLACK_BACKGROUND
)
SettingsCard(
title = "Testing option 2",
preferenceKey = PreferenceUtils.Key.LOAD_AUTOMATICALLY
)
}
}
}
@@ -40,7 +40,7 @@ class UserDirectoryProvider : DocumentsProvider() {
override fun onCreate(): Boolean { override fun onCreate(): Boolean {
val context = context ?: return false val context = context ?: return false
rootDir = LaunchUtils.getBaseDirectory(context) rootDir = LaunchUtils.getBaseDirectory(context, true)
return true return true
} }
@@ -0,0 +1,114 @@
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(
val filename: String,
val lastModified: Instant
)
class CrashViewModel(private val application: Application) : ViewModel() {
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
private fun getCrashList(): List<CrashDump> {
val crashDirectory = LaunchUtils.getCrashDirectory(application)
if (!crashDirectory.exists()) {
return emptyList()
}
val children = crashDirectory.listFiles {
// ignore indicator files (including old file)
_, name -> name != LaunchUtils.CRASH_INDICATOR_NAME && name != "last-pid"
} ?: return emptyList()
return children
.map {
CrashDump(
filename = it.name,
lastModified = Instant.fromEpochMilliseconds(it.lastModified())
)
}
.sortedByDescending { it.lastModified }
}
fun clearCrashes() {
logJob?.cancel()
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
}
}
fun removeCrash(filename: String) {
logJob?.cancel()
val crashPath = File(LaunchUtils.getCrashDirectory(application), filename)
if (crashPath.exists()) {
crashPath.delete()
loadLogs()
}
}
init {
loadLogs()
}
}
@@ -1,12 +1,17 @@
package com.geode.launcher.log package com.geode.launcher.log
import kotlinx.datetime.Clock import kotlin.time.Clock
import kotlinx.datetime.Instant import kotlin.time.Instant
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toJavaInstant
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import okio.Buffer
import okio.BufferedSource import okio.BufferedSource
import java.io.IOException import java.io.IOException
import kotlin.time.ExperimentalTime
import kotlin.time.toJavaInstant
fun BufferedSource.readCChar(): Char { fun BufferedSource.readCChar(): Char {
return this.readUtf8CodePoint().toChar() return this.readUtf8CodePoint().toChar()
@@ -24,10 +29,29 @@ fun BufferedSource.readCString(): String {
return buffer.toString() return buffer.toString()
} }
@Serializable
data class ProcessInformation(val processId: Int, val threadId: Int, val processUid: Int) data class ProcessInformation(val processId: Int, val threadId: Int, val processUid: Int)
@Serializable
enum class LogPriority { enum class LogPriority {
UNKNOWN, DEFAULT, VERBOSE, DEBUG, INFO, WARN, ERROR, FATAL, SILENT; @SerialName("unknown")
UNKNOWN,
@SerialName("default")
DEFAULT,
@SerialName("verbose")
VERBOSE,
@SerialName("debug")
DEBUG,
@SerialName("info")
INFO,
@SerialName("warn")
WARN,
@SerialName("error")
ERROR,
@SerialName("fatal")
FATAL,
@SerialName("silent")
SILENT;
class LogPriorityIterator( class LogPriorityIterator(
start: LogPriority, start: LogPriority,
@@ -114,6 +138,7 @@ enum class LogPriority {
/** /**
* Represents a log entry from logcat. * Represents a log entry from logcat.
*/ */
@Serializable
data class LogLine( data class LogLine(
val process: ProcessInformation, val process: ProcessInformation,
val time: Instant, val time: Instant,
@@ -133,6 +158,7 @@ data class LogLine(
else -> throw IOException("LogLine::fromInputStream: unknown format for (headerSize = $size)") else -> throw IOException("LogLine::fromInputStream: unknown format for (headerSize = $size)")
} }
@OptIn(ExperimentalTime::class)
fun fromBufferedSource(source: BufferedSource): LogLine { fun fromBufferedSource(source: BufferedSource): LogLine {
/* /*
// from android <liblog/include/log/log_read.h> // from android <liblog/include/log/log_read.h>
@@ -161,7 +187,7 @@ data class LogLine(
}; };
*/ */
/* val payloadLength = */ source.readShortLe() val payloadLength = source.readShortLe().toLong()
val headerSize = source.readShortLe().toUShort() val headerSize = source.readShortLe().toUShort()
val entryVersion = headerSizeToVersion(headerSize) val entryVersion = headerSizeToVersion(headerSize)
@@ -177,11 +203,17 @@ data class LogLine(
val processInformation = ProcessInformation(pid, tid, uid) val processInformation = ProcessInformation(pid, tid, uid)
val time = Instant.fromEpochSeconds(sec, nSec) val time = Instant.fromEpochSeconds(sec, nSec)
val priorityByte = source.readByte() // the payload is split into three parts
// initial priority byte -> null terminated tag -> non null terminated message
val packetBuffer = Buffer()
source.readFully(packetBuffer, payloadLength)
val priorityByte = packetBuffer.readByte()
val priority = LogPriority.fromByte(priorityByte) val priority = LogPriority.fromByte(priorityByte)
val tag = source.readCString() val tag = packetBuffer.readCString()
val message = source.readCString() val message = packetBuffer.readUtf8()
return LogLine( return LogLine(
process = processInformation, process = processInformation,
@@ -189,7 +221,7 @@ data class LogLine(
time = time, time = time,
logId = lid, logId = lid,
tag = tag, tag = tag,
message = message message = message.trimEnd('\u0000')
) )
} }
@@ -203,11 +235,15 @@ data class LogLine(
) )
} }
@Transient
val identifier = time.toJavaInstant() val identifier = time.toJavaInstant()
val messageTrimmed by lazy { this.message.trim() } val messageTrimmed by lazy {
this.message.trim { it <= ' ' }
}
val formattedTime by lazy { this.time.toLocalDateTime(TimeZone.currentSystemDefault()) } val formattedTime by lazy { this.time.toLocalDateTime(TimeZone.currentSystemDefault()) }
val asSimpleString by lazy { val asSimpleString by lazy {
"$formattedTime [${this.priority.toChar()}/${this.tag}]: ${this.messageTrimmed}" "$formattedTime [${this.priority.toChar()}/${this.tag}]: ${this.messageTrimmed}"
} }
@@ -9,7 +9,9 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import okio.Buffer
import okio.buffer import okio.buffer
import okio.source import okio.source
import java.io.EOFException import java.io.EOFException
@@ -61,7 +63,9 @@ class LogViewModel: ViewModel() {
else "logcat -B -d" else "logcat -B -d"
val logProcess = try { val logProcess = try {
Runtime.getRuntime().exec(logCommand) withContext(Dispatchers.IO) {
Runtime.getRuntime().exec(logCommand)
}
} catch (ioe: IOException) { } catch (ioe: IOException) {
ioe.printStackTrace() ioe.printStackTrace()
logLines += LogLine.showException(ioe) logLines += LogLine.showException(ioe)
@@ -71,13 +75,15 @@ class LogViewModel: ViewModel() {
return logLines return logLines
} }
val logSource = logProcess.inputStream.source().buffer() // read entire log into a buffer so no logs are added to the buffer during processing
val logBuffer = Buffer()
logProcess.inputStream.source().buffer().readAll(logBuffer)
try { try {
coroutineScope { coroutineScope {
// this runs until the stream is exhausted // this runs until the stream is exhausted
while (true) { while (true) {
val line = LogLine.fromBufferedSource(logSource) val line = LogLine.fromBufferedSource(logBuffer)
if (line.priority >= logLevel) { if (line.priority >= logLevel) {
logLines += line logLines += line
@@ -0,0 +1,89 @@
package com.geode.launcher.main
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.AbsoluteRoundedCornerShape
import androidx.compose.material3.ElevatedCard
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.geode.launcher.R
import com.geode.launcher.utils.Constants
fun onDownloadGame(context: Context) {
val appUrl = "https://play.google.com/store/apps/details?id=${Constants.PACKAGE_NAME}"
try {
val intent = Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(appUrl)
setPackage("com.android.vending")
}
context.startActivity(intent)
} catch (_: ActivityNotFoundException) {
Toast.makeText(context, R.string.no_activity_found, Toast.LENGTH_SHORT).show()
}
}
@Composable
fun GooglePlayBadge(modifier: Modifier = Modifier) {
val context = LocalContext.current
Image(
painter = painterResource(id = R.drawable.google_play_badge),
contentDescription = stringResource(R.string.launcher_download_game),
modifier = modifier
.width(196.dp)
.clip(AbsoluteRoundedCornerShape(4.dp))
.clickable { onDownloadGame(context) }
)
}
@Composable
fun StatusIndicator(
icon: @Composable () -> Unit,
text: @Composable () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
ElevatedCard(modifier = modifier, onClick = onClick) {
Row(modifier = Modifier.padding(8.dp)) {
icon()
Spacer(Modifier.size(8.dp))
text()
}
}
}
@Composable
fun InlineDialog(headline: @Composable () -> Unit, body: @Composable () -> Unit, modifier: Modifier = Modifier) {
ElevatedCard(modifier = modifier) {
Column(modifier = Modifier.padding(18.dp).sizeIn(maxWidth = 512.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(bottom = 8.dp)
) {
headline()
}
body()
}
}
}
@@ -0,0 +1,394 @@
package com.geode.launcher.main
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.os.Build
import android.provider.DocumentsContract
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
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
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withLink
import androidx.compose.ui.unit.dp
import com.geode.launcher.preferences.ApplicationLogsActivity
import com.geode.launcher.BuildConfig
import com.geode.launcher.R
import com.geode.launcher.UserDirectoryProvider
import com.geode.launcher.ui.theme.Typography
import com.geode.launcher.utils.GamePackageUtils
import com.geode.launcher.utils.LaunchUtils
import com.geode.launcher.utils.PreferenceUtils
data class LoadFailureInfo(
val title: LaunchUtils.LauncherError,
val description: String? = null,
val extendedMessage: String? = null
)
@Composable
fun ErrorInfoBody(failureReason: LaunchUtils.LauncherError, modifier: Modifier = Modifier) {
val headline = when (failureReason) {
LaunchUtils.LauncherError.LINKER_FAILED -> stringResource(R.string.load_failed_link_error_description)
LaunchUtils.LauncherError.CRASHED -> stringResource(R.string.load_failed_crashed_description)
else -> stringResource(R.string.load_failed_generic_error_description)
}
val context = LocalContext.current
val recommendations = when (failureReason) {
LaunchUtils.LauncherError.LINKER_FAILED_STL -> {
val isDeveloper = PreferenceUtils.get(context).getBoolean(PreferenceUtils.Key.DEVELOPER_MODE)
buildList {
add(stringResource(R.string.load_failed_recommendation_update))
add(stringResource(R.string.load_failed_recommendation_report))
if (isDeveloper)
add(stringResource(R.string.load_failed_recommendation_dev_stl))
}
}
LaunchUtils.LauncherError.LINKER_FAILED -> listOfNotNull(
stringResource(R.string.load_failed_recommendation_reinstall),
stringResource(R.string.load_failed_recommendation_update),
)
LaunchUtils.LauncherError.CRASHED -> listOf(
stringResource(R.string.load_failed_recommendation_safe_mode),
stringResource(R.string.load_failed_recommendation_report)
)
else -> listOf(
stringResource(R.string.load_failed_recommendation_reinstall),
stringResource(R.string.load_failed_recommendation_update),
stringResource(R.string.load_failed_recommendation_report)
)
}
Column(modifier = modifier) {
Text(headline)
recommendations.forEach { r ->
Text("\u2022\u00A0\u00A0$r")
}
}
}
@Composable
fun ErrorInfoTitle(failureReason: LaunchUtils.LauncherError) {
val message = when (failureReason) {
LaunchUtils.LauncherError.LINKER_NEEDS_64BIT,
LaunchUtils.LauncherError.LINKER_NEEDS_32BIT,
LaunchUtils.LauncherError.LINKER_FAILED_STL,
LaunchUtils.LauncherError.LINKER_FAILED -> stringResource(R.string.load_failed_link_error)
LaunchUtils.LauncherError.GENERIC -> stringResource(R.string.load_failed_generic_error)
LaunchUtils.LauncherError.CRASHED -> stringResource(R.string.load_failed_crashed)
}
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painterResource(R.drawable.icon_error),
contentDescription = stringResource(R.string.launcher_error_icon_alt),
modifier = Modifier.size(28.dp)
)
Text(message, style = Typography.headlineMedium)
}
}
fun onShareCrash(context: Context) {
val lastCrash = LaunchUtils.getLastCrash(context)
if (lastCrash == null) {
Toast.makeText(context, R.string.launcher_error_export_missing, Toast.LENGTH_SHORT).show()
return
}
val baseDirectory = LaunchUtils.getBaseDirectory(context)
val providerLocation = lastCrash.toRelativeString(baseDirectory)
val documentsPath = "${UserDirectoryProvider.ROOT}${providerLocation}"
val uri = DocumentsContract.buildDocumentUri("${context.packageName}.user", documentsPath)
val shareIntent = Intent(Intent.ACTION_SEND).apply {
putExtra(Intent.EXTRA_STREAM, uri)
type = "application/octet-stream"
}
try {
context.startActivity(shareIntent)
} catch (_: ActivityNotFoundException) {
Toast.makeText(context, R.string.no_activity_found, Toast.LENGTH_SHORT).show()
}
}
fun onShowLogs(context: Context) {
val launchIntent = Intent(context, ApplicationLogsActivity::class.java)
context.startActivity(launchIntent)
}
@Composable
fun ErrorInfoDescription(description: String, modifier: Modifier = Modifier) {
val context = LocalContext.current
val currentRelease = remember {
PreferenceUtils.get(context).getString(PreferenceUtils.Key.CURRENT_VERSION_TAG)
}
val gameVersion = remember {
GamePackageUtils.getUnifiedVersionName(context.packageManager)
}
Column(modifier = modifier) {
Text(
stringResource(R.string.launcher_error_details),
style = Typography.headlineSmall
)
Spacer(Modifier.size(8.dp))
Text(
description,
fontFamily = FontFamily.Monospace,
style = Typography.bodyMedium
)
Spacer(Modifier.size(4.dp))
Text(
stringResource(R.string.launcher_error_device_info,
Build.MODEL,
Build.VERSION.RELEASE,
currentRelease ?: "unknown",
gameVersion,
LaunchUtils.applicationArchitecture,
BuildConfig.VERSION_NAME
),
style = Typography.labelMedium
)
}
}
@Composable
@OptIn(ExperimentalLayoutApi::class)
fun ErrorInfoActions(extraDetails: String?, modifier: Modifier = Modifier) {
val clipboardManager = LocalClipboardManager.current
val context = LocalContext.current
FlowRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(
12.dp, alignment = Alignment.CenterHorizontally
)
) {
if (extraDetails != null) {
Button(onClick = { onShowLogs(context) }) {
Icon(
painterResource(R.drawable.icon_description),
contentDescription = null
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_error_view_logs))
}
FilledTonalButton(onClick = {
clipboardManager.setText(AnnotatedString(extraDetails))
}) {
Icon(
painterResource(R.drawable.icon_content_copy),
contentDescription = null
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_error_copy))
}
} else {
Button(onClick = { onShareCrash(context) }) {
Icon(
painterResource(R.drawable.icon_share),
contentDescription = null
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_error_export_crash))
}
val developerSettingsEnabled = remember {
PreferenceUtils.get(context).getBoolean(PreferenceUtils.Key.DEVELOPER_MODE)
}
if (developerSettingsEnabled) {
FilledTonalButton(onClick = { onShowLogs(context) }) {
Icon(
painterResource(R.drawable.icon_description),
contentDescription = null
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_error_view_logs))
}
}
}
}
}
@Composable
fun DownloadRecommendation(needsUniversal: Boolean, modifier: Modifier = Modifier) {
val context = LocalContext.current
val version = BuildConfig.VERSION_NAME
val showDownload = remember { GamePackageUtils.showDownloadBadge(context.packageManager) }
val downloadBase = "https://github.com/geode-sdk/android-launcher/releases/download/$version"
val legacyDownloadUrl = "$downloadBase/geode-launcher-v$version-android32.apk"
val universalDownloadUrl = "$downloadBase/geode-launcher-v$version.apk"
val downloadUrl = if (needsUniversal)
universalDownloadUrl else legacyDownloadUrl
val downloadText = if (needsUniversal)
stringResource(R.string.launcher_download_universal)
else
stringResource(R.string.launcher_download_32bit)
val promptTitle = stringResource(R.string.load_failed_abi_error)
val reinstallText = stringResource(R.string.load_failed_recommendation_reinstall)
val recommendationText = if (needsUniversal)
stringResource(R.string.load_failed_recommendation_switch_64bit_abi)
else
stringResource(R.string.load_failed_recommendation_switch_32bit_abi)
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(
12.dp, alignment = Alignment.CenterVertically
)
) {
Text(promptTitle)
Text("\u2022\u00A0\u00A0$reinstallText")
if (showDownload) {
GooglePlayBadge()
}
Text("\u2022\u00A0\u00A0$recommendationText")
Text(
buildAnnotatedString {
withLink(LinkAnnotation.Url(
downloadUrl,
TextLinkStyles(
style = SpanStyle(
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline
),
)
)) {
append(downloadText)
}
}
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ErrorInfoSheet(failureInfo: LoadFailureInfo, onDismiss: () -> Unit) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(onDismissRequest = { onDismiss() }, sheetState = sheetState) {
// second container created for scroll (before padding is added)
Box(Modifier.verticalScroll(rememberScrollState())) {
Column(modifier = Modifier
.padding(horizontal = 24.dp)
) {
ErrorInfoTitle(failureInfo.title)
Spacer(Modifier.size(16.dp))
if (failureInfo.title.isAbiFailure()) {
DownloadRecommendation(
needsUniversal = failureInfo.title == LaunchUtils.LauncherError.LINKER_NEEDS_64BIT,
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 16.dp)
)
} else {
ErrorInfoBody(failureInfo.title)
if (failureInfo.description != null) {
ErrorInfoDescription(
failureInfo.description,
modifier = Modifier.padding(vertical = 8.dp)
)
}
ErrorInfoActions(
extraDetails = failureInfo.extendedMessage ?: failureInfo.description,
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 16.dp)
)
}
}
}
}
}
suspend fun showFailureSnackbar(
context: Context,
loadFailureInfo: LoadFailureInfo,
snackbarHostState: SnackbarHostState,
onActionPerformed: () -> Unit
) {
val message = if (loadFailureInfo.title == LaunchUtils.LauncherError.CRASHED) {
context.getString(R.string.launcher_crashed)
} else {
context.getString(R.string.launcher_failed_to_load)
}
val res = snackbarHostState.showSnackbar(
message = message,
actionLabel = context.getString(R.string.launcher_error_more),
duration = SnackbarDuration.Indefinite,
)
when (res) {
SnackbarResult.ActionPerformed -> {
onActionPerformed()
}
SnackbarResult.Dismissed -> {}
}
}
@@ -0,0 +1,312 @@
package com.geode.launcher.main
import android.content.Context
import android.content.Intent
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.geode.launcher.GeometryDashActivity
import com.geode.launcher.R
import com.geode.launcher.preferences.SettingsActivity
import com.geode.launcher.ui.theme.Typography
import com.geode.launcher.utils.Constants
import com.geode.launcher.utils.GamePackageUtils
import com.geode.launcher.utils.GeodeUtils
import com.geode.launcher.utils.PreferenceUtils
import com.geode.launcher.utils.useCountdownTimer
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
@Composable
fun UpdateWarning(inSafeMode: Boolean = false, onDismiss: () -> Unit) {
val context = LocalContext.current
val packageManager = context.packageManager
val gdVersionCode = remember { GamePackageUtils.getGameVersionCode(packageManager) }
val gdVersionString = remember { GamePackageUtils.getGameVersionString(packageManager) }
var lastDismissedVersion by PreferenceUtils.useLongPreference(
preferenceKey = PreferenceUtils.Key.DISMISSED_GJ_UPDATE
)
val onUnsupportedVersion = gdVersionCode < Constants.SUPPORTED_VERSION_CODE_MIN || gdVersionCode > Constants.SUPPORTED_VERSION_CODE
val canDismissRelease = gdVersionCode >= Constants.SUPPORTED_VERSION_CODE
val shouldDismiss = canDismissRelease && gdVersionCode == lastDismissedVersion
if (onUnsupportedVersion && !shouldDismiss) {
AlertDialog(
icon = {
Icon(
painterResource(R.drawable.icon_warning),
contentDescription = stringResource(R.string.launcher_warning_icon_alt)
)
},
title = {
Text(stringResource(R.string.launcher_unsupported_version_title))
},
text = {
Text(
stringResource(
R.string.launcher_unsupported_version_description,
gdVersionString,
Constants.SUPPORTED_VERSION_STRING
)
)
},
confirmButton = {
TextButton(onClick = {
onDismiss()
lastDismissedVersion = gdVersionCode
onLaunch(context, inSafeMode)
}) {
Text(stringResource(R.string.message_box_accept))
}
},
dismissButton = {
TextButton(onClick = { onDismiss() }) {
Text(stringResource(R.string.message_box_cancel))
}
},
onDismissRequest = { onDismiss() }
)
} else {
LaunchedEffect(gdVersionCode) {
onLaunch(context, inSafeMode)
}
}
}
@Composable
fun SafeModeDialog(onDismiss: () -> Unit, onLaunch: () -> Unit) {
AlertDialog(
icon = {
Icon(
painterResource(R.drawable.icon_warning),
contentDescription = stringResource(R.string.launcher_warning_icon_alt)
)
},
title = { Text(stringResource(R.string.safe_mode_enable_title)) },
text = { Text(stringResource(R.string.safe_mode_enable_description)) },
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.message_box_cancel))
}
},
confirmButton = {
TextButton(onClick = onLaunch) {
Text(stringResource(R.string.message_box_continue))
}
},
onDismissRequest = onDismiss
)
}
fun onLaunch(context: Context, safeMode: Boolean = false) {
if (safeMode) {
GeodeUtils.setAdditionalLaunchArguments(GeodeUtils.ARGUMENT_SAFE_MODE)
} else {
GeodeUtils.clearLaunchArguments()
}
val launchIntent = Intent(context, GeometryDashActivity::class.java)
launchIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
context.startActivity(launchIntent)
}
fun onSettings(context: Context) {
val launchIntent = Intent(context, SettingsActivity::class.java)
context.startActivity(launchIntent)
}
@Composable
fun LongPressButton(onClick: () -> Unit, onLongPress: () -> Unit, enabled: Boolean = true, modifier: Modifier = Modifier, content: @Composable RowScope.() -> Unit) {
// compose apis don't provide a good way of adding long press to a button
var isLongPress by remember { mutableStateOf(false) }
val interactionSource = remember { MutableInteractionSource() }
val viewConfiguration = LocalViewConfiguration.current
val haptics = LocalHapticFeedback.current
LaunchedEffect(interactionSource) {
interactionSource.interactions.collectLatest { interaction ->
when (interaction) {
is PressInteraction.Press -> {
delay(viewConfiguration.longPressTimeoutMillis)
// perform a second delay to make the action more obvious
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
delay(viewConfiguration.longPressTimeoutMillis)
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
isLongPress = true
onLongPress()
}
is PressInteraction.Release -> {
if (!isLongPress) {
onClick()
}
isLongPress = false
}
}
}
}
Button(
onClick = {},
enabled = enabled,
modifier = modifier,
interactionSource = interactionSource,
content = content
)
}
@Composable
fun PlayButton(
stopAutomaticLaunch: Boolean,
blockLaunch: Boolean,
onPlayGame: (Boolean) -> Unit
) {
val context = LocalContext.current
var showSafeModeDialog by remember { mutableStateOf(false) }
var launchInSafeMode by remember { mutableStateOf(false) }
val shouldAutomaticallyLaunch by PreferenceUtils.useBooleanPreference(
preferenceKey = PreferenceUtils.Key.LOAD_AUTOMATICALLY
)
if (shouldAutomaticallyLaunch && !stopAutomaticLaunch && !showSafeModeDialog) {
val countdownTimer = useCountdownTimer(
time = 3000,
onCountdownFinish = { onPlayGame(launchInSafeMode) }
)
if (countdownTimer != 0L) {
Text(
pluralStringResource(
R.plurals.automatically_load_countdown,
countdownTimer.toInt(),
countdownTimer
),
style = Typography.bodyMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
Spacer(Modifier.size(12.dp))
}
}
Row {
LongPressButton(
enabled = !blockLaunch,
onLongPress = {
showSafeModeDialog = true
},
onClick = {
onPlayGame(launchInSafeMode)
}
) {
Icon(
painterResource(R.drawable.icon_play_arrow),
contentDescription = stringResource(R.string.launcher_launch_icon_alt)
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_launch))
}
Spacer(Modifier.size(2.dp))
IconButton(onClick = { onSettings(context) }) {
Icon(
painterResource(R.drawable.icon_settings),
contentDescription = stringResource(R.string.launcher_settings_icon_alt)
)
}
}
if (showSafeModeDialog) {
SafeModeDialog(
onDismiss = {
launchInSafeMode = false
showSafeModeDialog = false
},
onLaunch = {
launchInSafeMode = true
onPlayGame(true)
showSafeModeDialog = false
}
)
}
}
@Composable
fun LaunchBlockedLabel(text: String) {
val context = LocalContext.current
val showDownload = remember { GamePackageUtils.showDownloadBadge(context.packageManager) }
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(text, textAlign = TextAlign.Center, modifier = Modifier.padding(12.dp))
if (showDownload) {
GooglePlayBadge()
}
}
}
@Composable
fun UnsupportedVersionWarning(modifier: Modifier = Modifier) {
InlineDialog(
headline = {
Icon(
painterResource(R.drawable.icon_warning),
contentDescription = null,
modifier = Modifier.size(28.dp)
)
Text(
stringResource(R.string.launcher_unsupported_version_title),
style = MaterialTheme.typography.titleMedium,
)
},
body = {
Text(
stringResource(R.string.launcher_game_update_required),
)
},
modifier = modifier.padding(8.dp)
)
}
@@ -0,0 +1,257 @@
package com.geode.launcher.main
import android.content.Context
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.displayCutoutPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.geode.launcher.R
import com.geode.launcher.ui.theme.GeodeLauncherTheme
import com.geode.launcher.ui.theme.LocalTheme
import com.geode.launcher.ui.theme.Theme
import com.geode.launcher.updater.ReleaseManager
import com.geode.launcher.utils.Constants
import com.geode.launcher.utils.GamePackageUtils
import com.geode.launcher.utils.PreferenceUtils
import kotlinx.coroutines.delay
enum class LaunchNotificationType {
GEODE_UPDATED, LAUNCHER_UPDATE_AVAILABLE, UPDATE_FAILED, UNSUPPORTED_VERSION;
}
fun determineDisplayedCards(context: Context): List<LaunchNotificationType> {
val cards = mutableListOf<LaunchNotificationType>()
if (GamePackageUtils.getGameVersionCode(context.packageManager) < Constants.SUPPORTED_VERSION_CODE_MIN_WARNING) {
cards.add(LaunchNotificationType.UNSUPPORTED_VERSION)
}
val releaseManager = ReleaseManager.get(context)
val loadAutomatically = PreferenceUtils.get(context).getBoolean(PreferenceUtils.Key.LOAD_AUTOMATICALLY)
val availableUpdate = releaseManager.availableLauncherUpdate.value
if (loadAutomatically && availableUpdate != null) {
cards.add(LaunchNotificationType.LAUNCHER_UPDATE_AVAILABLE)
}
val releaseState = releaseManager.uiState.value
if (releaseState is ReleaseManager.ReleaseManagerState.Finished && releaseState.hasUpdated) {
cards.add(LaunchNotificationType.GEODE_UPDATED)
}
if (releaseState is ReleaseManager.ReleaseManagerState.Failure) {
cards.add(LaunchNotificationType.UPDATE_FAILED)
}
return cards
}
@Composable
fun NotificationCardFromType(type: LaunchNotificationType) {
when (type) {
LaunchNotificationType.LAUNCHER_UPDATE_AVAILABLE -> {
var showInfoDialog by remember { mutableStateOf(false) }
if (showInfoDialog) {
LauncherUpdateInformation {
showInfoDialog = false
}
}
AnimatedNotificationCard(
displayLength = 5000L,
onClick = {
showInfoDialog = true
}
) {
LauncherUpdateContent()
}
}
LaunchNotificationType.UPDATE_FAILED -> {
AnimatedNotificationCard {
UpdateFailedContent()
}
}
LaunchNotificationType.UNSUPPORTED_VERSION -> {
AnimatedNotificationCard {
OutdatedVersionContent()
}
}
else -> {}
}
}
@Composable
fun LaunchNotification() {
val context = LocalContext.current
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
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
Column(
modifier = Modifier.displayCutoutPadding()
) {
cards.forEach {
NotificationCardFromType(it)
}
}
}
}
}
}
@Composable
fun AnimatedNotificationCard(modifier: Modifier = Modifier, visibilityDelay: Long = 0L, displayLength: Long = 3000L, onClick: (() -> Unit)? = null, contents: @Composable () -> Unit) {
val state = remember {
MutableTransitionState(false).apply {
targetState = visibilityDelay <= 0
}
}
LaunchedEffect(true) {
if (visibilityDelay > 0) {
delay(visibilityDelay)
state.targetState = true
}
delay(displayLength)
state.targetState = false
}
AnimatedVisibility(
visibleState = state,
enter = expandHorizontally() + fadeIn(),
exit = shrinkHorizontally() + fadeOut(),
modifier = modifier
) {
CardView(onClick, modifier = Modifier.padding(8.dp)) {
contents()
}
}
}
@Composable
fun CardView(onClick: (() -> Unit)?, modifier: Modifier = Modifier, contents: @Composable () -> Unit) {
// val surfaceColor = MaterialTheme.colorScheme.background.copy(alpha = 0.75f)
if (onClick != null) {
OutlinedCard(
onClick = onClick,
modifier = modifier
) {
contents()
}
} else {
OutlinedCard(
modifier = modifier
) {
contents()
}
}
}
@Composable
fun LauncherUpdateContent(modifier: Modifier = Modifier) {
ListItem(
headlineContent = {
Text(text = stringResource(id = R.string.launcher_update_available))
},
supportingContent = {
Text(text = stringResource(id = R.string.launcher_notification_update_cta))
},
leadingContent = {
Icon(
painterResource(R.drawable.icon_info),
contentDescription = null,
)
},
modifier = modifier.width(IntrinsicSize.Max)
)
}
@Composable
fun UpdateNotificationContent(modifier: Modifier = Modifier) {
ListItem(
headlineContent = {
Text(text = stringResource(id = R.string.launcher_notification_update_success))
},
leadingContent = {
Icon(
painterResource(R.drawable.geode_monochrome),
contentDescription = null,
modifier = Modifier.size(24.dp)
)
},
modifier = modifier.width(IntrinsicSize.Max)
)
}
@Composable
fun OutdatedVersionContent(modifier: Modifier = Modifier) {
ListItem(
headlineContent = {
Text(text = stringResource(id = R.string.launcher_notification_compatibility))
},
leadingContent = {
Icon(
painterResource(R.drawable.icon_warning),
contentDescription = null,
)
},
supportingContent = {
Text(text = stringResource(id = R.string.launcher_notification_compatibility_description))
},
modifier = modifier.width(IntrinsicSize.Max)
)
}
@Composable
fun UpdateFailedContent(modifier: Modifier = Modifier) {
ListItem(
headlineContent = {
Text(text = stringResource(id = R.string.launcher_notification_update_failed))
},
leadingContent = {
Icon(
painterResource(R.drawable.icon_warning),
contentDescription = null,
)
},
modifier = modifier.width(IntrinsicSize.Max)
)
}
@@ -0,0 +1,228 @@
package com.geode.launcher.main
import android.app.Application
import android.os.CountDownTimer
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.geode.launcher.updater.ReleaseManager
import com.geode.launcher.utils.Constants
import com.geode.launcher.utils.GamePackageUtils
import com.geode.launcher.utils.LaunchUtils
import com.geode.launcher.utils.PreferenceUtils
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch
private const val COOLDOWN_LENGTH_MS = 3000L
class LaunchViewModel(private val application: Application): ViewModel() {
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as Application
LaunchViewModel(
application = application
)
}
}
}
private var readyTimerPassed = false
private var readyTimer: CountDownTimer? = null
private fun initReadyTimer() {
readyTimer = object : CountDownTimer(COOLDOWN_LENGTH_MS, 1000) {
override fun onTick(millisUntilFinished: Long) {
// no tick necessary
}
override fun onFinish() {
readyTimerPassed = true
if (_uiState.value is LaunchUIState.Working) {
viewModelScope.launch {
preReadyCheck()
}
}
}
}.start()
}
enum class LaunchCancelReason {
MANUAL, AUTOMATIC, LAST_LAUNCH_CRASHED, GEODE_NOT_FOUND, GAME_MISSING, GAME_OUTDATED;
fun allowsRetry() = when (this) {
MANUAL,
AUTOMATIC,
LAST_LAUNCH_CRASHED,
GEODE_NOT_FOUND -> true
GAME_MISSING,
GAME_OUTDATED -> false
}
fun isGameInstallIssue() = when (this) {
GAME_OUTDATED,
GAME_MISSING -> true
else -> false
}
}
sealed class LaunchUIState {
data object Initial : LaunchUIState()
data object UpdateCheck : LaunchUIState()
data class Updating(val downloaded: Long, val outOf: Long?) : LaunchUIState()
data class Cancelled(val reason: LaunchCancelReason, val inProgress: Boolean = false) : LaunchUIState()
data object Working : LaunchUIState()
data object Ready : LaunchUIState()
}
private val _uiState = MutableStateFlow<LaunchUIState>(LaunchUIState.Initial)
val uiState = _uiState.asStateFlow()
val nextLauncherUpdate = ReleaseManager.get(application).availableLauncherUpdate
var loadFailure: LoadFailureInfo? = null
private var hasCheckedForUpdates = false
private var isCancelling = false
private var hasManuallyStarted = false
private suspend fun determineUpdateStatus() {
ReleaseManager.get(application).uiState.takeWhile {
it is ReleaseManager.ReleaseManagerState.InUpdateCheck || it is ReleaseManager.ReleaseManagerState.InDownload
}.map { when (it) {
is ReleaseManager.ReleaseManagerState.InUpdateCheck -> LaunchUIState.UpdateCheck
is ReleaseManager.ReleaseManagerState.InDownload -> LaunchUIState.Updating(it.downloaded, it.outOf)
else -> LaunchUIState.UpdateCheck
}
}.collect {
_uiState.emit(it)
}
// flow terminates once update check is finished
hasCheckedForUpdates = true
preReadyCheck()
}
fun currentCrashInfo(): LoadFailureInfo? {
val currentState = _uiState.value
if (currentState is LaunchUIState.Cancelled && currentState.reason == LaunchCancelReason.LAST_LAUNCH_CRASHED) {
return loadFailure
}
return null
}
fun clearCrashInfo() {
val currentState = _uiState.value
if (currentState is LaunchUIState.Cancelled && currentState.reason == LaunchCancelReason.LAST_LAUNCH_CRASHED) {
loadFailure = null
}
}
private suspend fun preReadyCheck() {
if (isCancelling) {
// don't let the game start if cancelled
return
}
val packageManager = application.packageManager
if (!GamePackageUtils.isGameInstalled(packageManager)) {
return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_MISSING))
}
if (ReleaseManager.get(application).isInUpdate) {
viewModelScope.launch {
determineUpdateStatus()
}
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))
}
if (!LaunchUtils.isGeodeInstalled(application)) {
return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GEODE_NOT_FOUND))
}
val loadAutomatically = PreferenceUtils.get(application).getBoolean(PreferenceUtils.Key.LOAD_AUTOMATICALLY)
if (!loadAutomatically && !hasManuallyStarted) {
return cancelLaunch(true)
}
if (!readyTimerPassed && !hasManuallyStarted) {
return _uiState.emit(LaunchUIState.Working)
}
_uiState.emit(LaunchUIState.Ready)
}
suspend fun beginLaunchFlow(isRestart: Boolean = false) {
if (isRestart) {
hasManuallyStarted = true
}
if (_uiState.value !is LaunchUIState.Initial && _uiState.value !is LaunchUIState.Cancelled) {
return
}
if (_uiState.value is LaunchUIState.Cancelled && !isRestart) {
return
}
isCancelling = false
if (!GamePackageUtils.isGameInstalled(application.packageManager)) {
return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_MISSING))
}
if (readyTimer == null) {
initReadyTimer()
}
val hasGeode = LaunchUtils.isGeodeInstalled(application)
val shouldUpdate = PreferenceUtils.get(application).getBoolean(PreferenceUtils.Key.UPDATE_AUTOMATICALLY)
if ((shouldUpdate && !hasCheckedForUpdates) || !hasGeode) {
ReleaseManager.get(application).checkForUpdates(false)
}
preReadyCheck()
}
suspend fun cancelLaunch(isAutomatic: Boolean = false) {
// no need to double cancel
if (_uiState.value is LaunchUIState.Cancelled) {
return
}
val reason = if (isAutomatic) LaunchCancelReason.AUTOMATIC else LaunchCancelReason.MANUAL
isCancelling = true
_uiState.emit(LaunchUIState.Cancelled(reason, true))
val releaseManager = ReleaseManager.get(application)
if (releaseManager.isInUpdate) {
releaseManager.cancelUpdate()
}
_uiState.emit(LaunchUIState.Cancelled(reason, false))
}
override fun onCleared() {
super.onCleared()
readyTimer?.cancel()
}
}
@@ -0,0 +1,432 @@
package com.geode.launcher.main
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.DocumentsContract
import android.text.format.Formatter
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.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.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.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import com.geode.launcher.R
import com.geode.launcher.UserDirectoryProvider
import com.geode.launcher.ui.theme.Typography
import com.geode.launcher.updater.ReleaseManager
import com.geode.launcher.updater.ReleaseViewModel
import com.geode.launcher.utils.LaunchUtils
import com.geode.launcher.utils.PreferenceUtils
import com.mikepenz.markdown.compose.LocalBulletListHandler
import com.mikepenz.markdown.m3.Markdown
import com.mikepenz.markdown.m3.markdownTypography
import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor
import java.io.File
import java.net.ConnectException
import java.net.UnknownHostException
import java.text.DateFormat
import java.util.Date
import kotlin.time.ExperimentalTime
import kotlin.time.toJavaInstant
fun clearDownloadedApks(context: Context) {
// technically we should be using the activity results but it was too inconsistent for my liking
val preferenceUtils = PreferenceUtils.get(context)
val performCleanup = preferenceUtils.getBoolean(PreferenceUtils.Key.CLEANUP_APKS)
if (!performCleanup) {
return
}
val baseDirectory = LaunchUtils.getBaseDirectory(context)
baseDirectory.listFiles {
// only select apk files
_, name -> name.lowercase().endsWith(".apk")
}?.forEach {
it.delete()
}
preferenceUtils.setBoolean(PreferenceUtils.Key.CLEANUP_APKS, false)
}
fun generateInstallIntent(uri: Uri): Intent {
// maybe one day i'll rewrite this to use packageinstaller. not today
return Intent(Intent.ACTION_INSTALL_PACKAGE).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
}
fun installLauncherUpdate(context: Context) {
val nextUpdate = ReleaseManager.get(context).availableLauncherUpdate.value
val launcherDownload = nextUpdate?.getDownload()
if (launcherDownload != null) {
val outputFile = launcherDownload.filename
val baseDirectory = LaunchUtils.getBaseDirectory(context)
val outputPathFile = File(baseDirectory, outputFile)
if (!outputPathFile.exists()) {
return
}
val outputPath = "${UserDirectoryProvider.ROOT}${outputFile}"
val uri = DocumentsContract.buildDocumentUri("${context.packageName}.user", outputPath)
PreferenceUtils.get(context).setBoolean(PreferenceUtils.Key.CLEANUP_APKS, true)
try {
val intent = generateInstallIntent(uri)
context.startActivity(intent)
} catch (_: ActivityNotFoundException) {
// if it fails to install, just open it in the browser
try {
val downloadUrl = launcherDownload.url
val downloadIntent = Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(downloadUrl)
}
context.startActivity(downloadIntent)
} catch (_: ActivityNotFoundException) {
Toast.makeText(context, context.getString(R.string.no_activity_found), Toast.LENGTH_SHORT).show()
}
}
} else {
Toast.makeText(context, context.getString(R.string.release_fetch_no_releases), Toast.LENGTH_SHORT).show()
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalTime::class)
@Composable
fun LauncherUpdateInformation(onDismiss: () -> Unit) {
val context = LocalContext.current
val nextRelease = remember { ReleaseManager.get(context).availableLauncherUpdate.value }
val sheetState = rememberModalBottomSheetState()
if (nextRelease != 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),
style = Typography.headlineSmall,
)
val releasedTime = remember {
DateFormat.getDateInstance().format(
Date.from(nextRelease.release.publishedAt.toJavaInstant())
)
}
Text(
stringResource(R.string.launcher_update_released, "$releasedTime"),
style = Typography.labelLarge
)
Row(modifier = Modifier.padding(vertical = 12.dp)) {
Button(
onClick = { installLauncherUpdate(context) },
) {
Icon(painterResource(R.drawable.icon_download), contentDescription = null)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_install))
}
val uriHandler = LocalUriHandler.current
IconButton(onClick = { uriHandler.openUri(nextRelease.release.htmlUrl) }) {
Icon(
painterResource(R.drawable.icon_link),
stringResource(R.string.launcher_update_external_link)
)
}
}
if (nextRelease.release.body != null) {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text(
stringResource(R.string.launcher_update_changelog),
style = Typography.titleLarge
)
// every new release of the markdown library adds another arg to it
CompositionLocalProvider(LocalBulletListHandler provides { _, _, _, _, _ -> "•  " }) {
Markdown(
content = nextRelease.release.body.replace("\r", ""),
typography = markdownTypography(
textLink = TextLinkStyles(
style = MaterialTheme.typography.bodyLarge.copy(
textDecoration = TextDecoration.Underline,
color = MaterialTheme.colorScheme.primary,
).toSpanStyle()
),
),
flavour = GFMFlavourDescriptor(),
)
}
}
Spacer(modifier = Modifier.size(4.dp))
}
}
}
}
}
@Composable
fun LauncherUpdateIndicator(modifier: Modifier = Modifier) {
val context = LocalContext.current
var showInfoDialog by remember { mutableStateOf(false) }
if (showInfoDialog) {
LauncherUpdateInformation {
showInfoDialog = false
}
}
ElevatedCard(modifier) {
Column(
// buttons add enough padding already, lower to compensate
modifier = Modifier.padding(
top = 20.dp,
start = 12.dp,
end = 12.dp,
bottom = 8.dp
),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
stringResource(R.string.launcher_update_available),
modifier = Modifier.padding(horizontal = 10.dp)
)
Row(modifier = Modifier.align(Alignment.End)) {
TextButton(
onClick = { showInfoDialog = true },
) {
Text(stringResource(R.string.launcher_update_view))
}
TextButton(
onClick = { installLauncherUpdate(context) },
) {
Text(stringResource(R.string.launcher_install))
}
}
}
}
}
@Composable
fun UpdateProgressIndicator(
message: String,
releaseViewModel: ReleaseViewModel,
modifier: Modifier = Modifier,
progress: (() -> Float)? = null
) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.Start,
modifier = modifier
) {
Text(message, style = MaterialTheme.typography.labelMedium)
Spacer(modifier = Modifier.padding(4.dp))
if (progress == null) {
LinearProgressIndicator()
} else {
LinearProgressIndicator(progress = progress)
}
TextButton(
onClick = {
releaseViewModel.cancelUpdate()
},
modifier = Modifier.offset((-12).dp)
) {
Text(stringResource(R.string.release_fetch_button_cancel))
}
}
}
@Composable
fun UpdateMessageIndicator(
message: String,
releaseViewModel: ReleaseViewModel,
modifier: Modifier = Modifier,
allowRetry: Boolean = false
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
) {
Text(
message,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.padding(4.dp))
if (allowRetry) {
OutlinedButton(
onClick = {
releaseViewModel.runReleaseCheck(true)
},
) {
Icon(
painterResource(R.drawable.icon_refresh),
contentDescription = stringResource(R.string.launcher_retry_icon_alt)
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.release_fetch_button_retry))
}
}
}
}
@Composable
fun UpdateCard(releaseViewModel: ReleaseViewModel, modifier: Modifier = Modifier) {
val context = LocalContext.current
val releaseState by releaseViewModel.uiState.collectAsState()
when (val state = releaseState) {
is ReleaseViewModel.ReleaseUIState.Failure -> {
val message = when (state.exception) {
is UnknownHostException, is ConnectException ->
stringResource(R.string.release_fetch_no_internet)
is ReleaseManager.UpdateException -> {
when (state.exception.reason) {
ReleaseManager.UpdateException.Reason.EXTERNAL_FILE_IN_USE -> stringResource(
R.string.release_fetch_manual_check_required
)
else -> null
}
}
else -> null
}
UpdateMessageIndicator(
stringResource(
R.string.release_fetch_failed,
message ?: stringResource(R.string.release_fetch_try_later)
),
modifier = modifier,
allowRetry = true,
releaseViewModel = releaseViewModel
)
}
is ReleaseViewModel.ReleaseUIState.InDownload -> {
val downloaded = remember(state.downloaded) {
Formatter.formatShortFileSize(context, state.downloaded)
}
val outOf = remember(state.outOf) {
state.outOf?.run {
Formatter.formatShortFileSize(context, this)
}
}
UpdateProgressIndicator(
if (outOf != null) stringResource(
R.string.release_fetch_downloading,
downloaded,
outOf
) else stringResource(
R.string.release_fetch_downloading_indeterminate,
downloaded,
),
modifier = modifier,
releaseViewModel = releaseViewModel,
progress = {
val progress = state.downloaded / state.outOf!!.toDouble()
progress.toFloat()
}.takeIf { state.outOf != null }
)
}
is ReleaseViewModel.ReleaseUIState.InUpdateCheck -> {
UpdateProgressIndicator(
stringResource(R.string.release_fetch_in_progress),
modifier = modifier,
releaseViewModel = releaseViewModel
)
}
is ReleaseViewModel.ReleaseUIState.Finished -> {
if (state.hasUpdated) {
UpdateMessageIndicator(
stringResource(R.string.release_fetch_success),
modifier = modifier,
releaseViewModel = releaseViewModel
)
}
}
is ReleaseViewModel.ReleaseUIState.Cancelled -> {
if (state.isCancelling) {
UpdateProgressIndicator(
stringResource(R.string.release_fetch_cancelling),
modifier = modifier,
releaseViewModel = releaseViewModel
)
} else {
UpdateMessageIndicator(
stringResource(R.string.release_fetch_cancelled),
modifier = modifier,
allowRetry = true,
releaseViewModel = releaseViewModel
)
}
}
}
val nextUpdate by releaseViewModel.nextLauncherUpdate.collectAsState()
if (!releaseViewModel.isInUpdate && nextUpdate != null) {
LauncherUpdateIndicator(
modifier = modifier
)
}
}
@@ -0,0 +1,204 @@
package com.geode.launcher.preferences
import android.content.ComponentName
import android.content.Context
import android.content.pm.PackageManager
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedDispatcher
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.geode.launcher.R
import com.geode.launcher.ui.theme.GeodeLauncherTheme
import com.geode.launcher.ui.theme.LocalTheme
import com.geode.launcher.ui.theme.Theme
import com.geode.launcher.utils.PreferenceUtils
import androidx.compose.ui.semantics.Role
import com.geode.launcher.utils.ApplicationIcon
import com.geode.launcher.utils.ApplicationIconDetails
import com.geode.launcher.utils.IconUtils
import com.geode.launcher.utils.adaptiveIconPainterResource
class ApplicationIconActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
val theme = Theme.fromInt(themeOption)
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
CompositionLocalProvider(LocalTheme provides theme) {
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
ApplicationIconScreen(onBackPressedDispatcher)
}
}
}
}
}
}
fun onSelectIcon(context: Context, selectedIcon: ApplicationIconDetails) {
val packageManager = context.packageManager
try {
ApplicationIcon.entries.forEach {
val iconData = IconUtils.getIconDetails(it)
packageManager.setComponentEnabledSetting(
ComponentName(context, "${context.packageName}.${iconData.component}"),
if (iconData.id == selectedIcon.id)
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
else PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
PackageManager.DONT_KILL_APP
)
}
} catch (_: IllegalArgumentException) {
Toast.makeText(context, R.string.application_icon_set_failed, Toast.LENGTH_SHORT).show()
}
PreferenceUtils.get(context).setString(PreferenceUtils.Key.SELECTED_ICON, selectedIcon.id)
}
@Composable
fun IconCard(iconData: ApplicationIconDetails, selected: Boolean, modifier: Modifier = Modifier) {
val context = LocalContext.current
Row(
Modifier
.fillMaxWidth()
.padding(vertical = 1.dp)
.clip(RoundedCornerShape(4.dp))
.then(modifier)
.selectable(
selected = selected,
onClick = {
onSelectIcon(context, iconData)
},
role = Role.RadioButton
)
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp, vertical = 8.dp),
Arrangement.SpaceBetween,
Alignment.CenterVertically,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Image(
adaptiveIconPainterResource(iconData.iconId),
contentDescription = null
)
Text(stringResource(iconData.nameId))
}
RadioButton(
selected = selected,
onClick = null
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ApplicationIconScreen(
onBackPressedDispatcher: OnBackPressedDispatcher?,
) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
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)
)
}
},
title = {
Text(
stringResource(R.string.title_activity_application_icon),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
scrollBehavior = scrollBehavior,
)
}
}
) { innerPadding ->
Column(
Modifier
.padding(innerPadding)
.fillMaxWidth(),
verticalArrangement = Arrangement.SpaceEvenly,
) {
val selectedIcon by PreferenceUtils.useStringPreference(PreferenceUtils.Key.SELECTED_ICON)
Column(Modifier
.padding(horizontal = 12.dp)
.clip(RoundedCornerShape(16.dp))
) {
ApplicationIcon.entries.forEach {
val iconData = IconUtils.getIconDetails(it)
IconCard(iconData, selectedIcon == iconData.id)
}
}
Spacer(Modifier.height(4.dp))
}
}
}
@@ -1,10 +1,6 @@
package com.geode.launcher package com.geode.launcher.preferences
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.widget.Toast import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
@@ -22,11 +18,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -57,6 +48,7 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
@@ -66,6 +58,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.geode.launcher.R
import com.geode.launcher.log.LogLine import com.geode.launcher.log.LogLine
import com.geode.launcher.log.LogPriority import com.geode.launcher.log.LogPriority
import com.geode.launcher.log.LogViewModel import com.geode.launcher.log.LogViewModel
@@ -73,6 +66,7 @@ import com.geode.launcher.ui.theme.GeodeLauncherTheme
import com.geode.launcher.ui.theme.LocalTheme import com.geode.launcher.ui.theme.LocalTheme
import com.geode.launcher.ui.theme.Theme import com.geode.launcher.ui.theme.Theme
import com.geode.launcher.ui.theme.Typography import com.geode.launcher.ui.theme.Typography
import com.geode.launcher.ui.theme.robotoMonoFamily
import com.geode.launcher.utils.PreferenceUtils import com.geode.launcher.utils.PreferenceUtils
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -86,9 +80,10 @@ class ApplicationLogsActivity : ComponentActivity() {
val theme = Theme.fromInt(themeOption) val theme = Theme.fromInt(themeOption)
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND) val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
CompositionLocalProvider(LocalTheme provides theme) { CompositionLocalProvider(LocalTheme provides theme) {
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption) { GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background color = MaterialTheme.colorScheme.background
@@ -138,12 +133,14 @@ fun SelectLogLevelDialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
onSelect = { onSelect(it) }, onSelect = { onSelect(it) },
initialValue = initialValue, initialValue = initialValue,
toLabel = { ) {
// i'm lazy (LogPriority.VERBOSE..LogPriority.FATAL).reversed().forEach {
it.name.lowercase().replaceFirstChar { c -> c.uppercaseChar() } SelectOption(
}, name = it.name.lowercase().replaceFirstChar { c -> c.uppercaseChar() },
optionsCount = LogPriority.FATAL downTo LogPriority.VERBOSE value = it
) )
}
}
} }
@Composable @Composable
@@ -167,12 +164,12 @@ fun LogCard(logLine: LogLine, modifier: Modifier = Modifier) {
"[${logLine.priority.toChar()}] ${logLine.formattedTime}", "[${logLine.priority.toChar()}] ${logLine.formattedTime}",
style = Typography.labelSmall, style = Typography.labelSmall,
color = getLogColor(logLine.priority), color = getLogColor(logLine.priority),
fontFamily = FontFamily.Monospace fontFamily = robotoMonoFamily
) )
Text( Text(
"[${logLine.tag}]: ${logLine.messageTrimmed}", "[${logLine.tag}]: ${logLine.messageTrimmed}",
fontFamily = FontFamily.Monospace, fontFamily = robotoMonoFamily,
style = Typography.bodyMedium style = Typography.bodyMedium
) )
} }
@@ -188,6 +185,7 @@ fun ApplicationLogsScreen(
val isLoading by logViewModel.isLoading.collectAsState() val isLoading by logViewModel.isLoading.collectAsState()
val context = LocalContext.current val context = LocalContext.current
val resources = LocalResources.current
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
@@ -235,7 +233,7 @@ fun ApplicationLogsScreen(
navigationIcon = { navigationIcon = {
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) { IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack, painterResource(R.drawable.icon_arrow_back),
contentDescription = stringResource(R.string.back_icon_alt) contentDescription = stringResource(R.string.back_icon_alt)
) )
} }
@@ -243,26 +241,17 @@ fun ApplicationLogsScreen(
actions = { actions = {
IconButton(onClick = { IconButton(onClick = {
coroutineScope.launch { coroutineScope.launch {
val data = logViewModel.getLogData() saveLauncher.launch(resources.getString(R.string.application_logs_default_filename))
val sendIntent = Intent(Intent.ACTION_SEND).apply {
putExtra(Intent.EXTRA_TEXT, data)
type = "text/plain"
}
val shareIntent = Intent.createChooser(sendIntent, null)
context.startActivity(shareIntent)
} }
}, enabled = !logLines.isEmpty()) { }, enabled = !logLines.isEmpty()) {
Icon( Icon(
Icons.Filled.Share, painterResource(R.drawable.icon_save),
contentDescription = stringResource(R.string.application_logs_share) contentDescription = stringResource(R.string.application_logs_export),
) )
} }
IconButton(onClick = { showMoreOptions = !showMoreOptions }) { IconButton(onClick = { showMoreOptions = !showMoreOptions }) {
Icon( Icon(
Icons.Filled.MoreVert, painterResource(R.drawable.icon_more_vert),
contentDescription = stringResource(R.string.application_logs_more) contentDescription = stringResource(R.string.application_logs_more)
) )
} }
@@ -281,7 +270,7 @@ fun ApplicationLogsScreen(
trailingIcon = { trailingIcon = {
if (logViewModel.filterCrashes) { if (logViewModel.filterCrashes) {
Icon( Icon(
Icons.Filled.Check, painterResource(R.drawable.icon_check),
contentDescription = stringResource(R.string.application_logs_crash_only_enabled_alt) contentDescription = stringResource(R.string.application_logs_crash_only_enabled_alt)
) )
} }
@@ -310,23 +299,6 @@ fun ApplicationLogsScreen(
} }
) )
DropdownMenuItem(
leadingIcon = {
Icon(
painterResource(R.drawable.icon_save),
contentDescription = null,
)
},
text = {
Text(stringResource(R.string.application_logs_export))
},
onClick = {
saveLauncher.launch(context.getString(R.string.application_logs_default_filename))
showMoreOptions = false
},
enabled = !logLines.isEmpty()
)
DropdownMenuItem( DropdownMenuItem(
leadingIcon = { leadingIcon = {
Icon( Icon(
@@ -0,0 +1,306 @@
package com.geode.launcher.preferences
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.provider.DocumentsContract
import android.widget.Toast
import java.text.DateFormat
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedDispatcher
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.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.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.geode.launcher.R
import com.geode.launcher.UserDirectoryProvider
import com.geode.launcher.log.CrashDump
import com.geode.launcher.log.CrashViewModel
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() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
val theme = Theme.fromInt(themeOption)
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
CompositionLocalProvider(LocalTheme provides theme) {
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
CrashLogsScreen(onBackPressedDispatcher)
}
}
}
}
}
}
fun shareCrash(context: Context, filename: String) {
val baseDirectory = LaunchUtils.getBaseDirectory(context)
val crashPath = File(LaunchUtils.getCrashDirectory(context), filename)
if (!crashPath.exists()) {
Toast.makeText(context, R.string.launcher_error_export_missing, Toast.LENGTH_SHORT).show()
return
}
val providerLocation = crashPath.toRelativeString(baseDirectory)
val documentsPath = "${UserDirectoryProvider.ROOT}${providerLocation}"
val uri = DocumentsContract.buildDocumentUri("${context.packageName}.user", documentsPath)
val shareIntent = Intent(Intent.ACTION_SEND).apply {
putExtra(Intent.EXTRA_STREAM, uri)
type = "application/octet-stream"
}
try {
context.startActivity(shareIntent)
} catch (_: ActivityNotFoundException) {
Toast.makeText(context, R.string.no_activity_found, Toast.LENGTH_SHORT).show()
}
}
@OptIn(ExperimentalTime::class)
@Composable
fun CrashCard(crashDump: CrashDump, crashViewModel: CrashViewModel, modifier: Modifier = Modifier) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier
.fillMaxWidth()
.padding(vertical = 1.dp, horizontal = 8.dp)
.height(64.dp)
.then(modifier)
.background(MaterialTheme.colorScheme.surfaceContainerLow)
.padding(vertical = 8.dp, horizontal = 8.dp)
) {
Column(
verticalArrangement = Arrangement.SpaceAround,
modifier = Modifier.fillMaxHeight().offset(x = 12.dp).weight(0.7f)
) {
Text(
crashDump.filename,
fontFamily = robotoMonoFamily,
style = MaterialTheme.typography.titleSmall,
overflow = TextOverflow.Ellipsis,
maxLines = 1
)
val formattedLastModified = remember {
DateFormat.getDateTimeInstance()
.format(
Date.from(crashDump.lastModified.toJavaInstant())
)
}
Text(formattedLastModified, style = MaterialTheme.typography.labelMedium)
}
Row(modifier = Modifier.weight(0.3f), horizontalArrangement = Arrangement.End) {
val context = LocalContext.current
IconButton(onClick = {
shareCrash(context, crashDump.filename)
}) {
Icon(
painterResource(R.drawable.icon_share),
contentDescription = stringResource(R.string.application_logs_share)
)
}
IconButton(onClick = {
crashViewModel.removeCrash(crashDump.filename)
}) {
Icon(
painterResource(R.drawable.icon_delete),
contentDescription = stringResource(R.string.application_crashes_delete_one)
)
}
}
}
}
@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 }
) {
DropdownMenuItem(
leadingIcon = {
Icon(
painterResource(R.drawable.icon_delete),
contentDescription = null
)
},
text = {
Text(stringResource(R.string.application_logs_delete))
}, onClick = {
crashViewModel.clearCrashes()
showMoreOptions = false
}
)
}
},
title = {
Text(
stringResource(R.string.title_activity_crash_logs),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
scrollBehavior = scrollBehavior,
)
if (isLoading) {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth()
)
}
}
}
) { 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
)
CrashCard(
line,
crashViewModel,
modifier = Modifier.clip(shape)
)
}
item {
Spacer(Modifier.height(4.dp))
}
}
}
}
@Preview(showSystemUi = true)
@Composable
fun CrashLogsPreview() {
GeodeLauncherTheme {
CrashLogsScreen(null)
}
}
@@ -0,0 +1,239 @@
package com.geode.launcher.preferences
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedDispatcher
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.geode.launcher.R
import com.geode.launcher.ui.theme.GeodeLauncherTheme
import com.geode.launcher.ui.theme.LocalTheme
import com.geode.launcher.ui.theme.Theme
import com.geode.launcher.utils.LaunchUtils
import com.geode.launcher.utils.PreferenceUtils
class DeveloperSettingsActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
val theme = Theme.fromInt(themeOption)
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
CompositionLocalProvider(LocalTheme provides theme) {
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
DeveloperSettingsScreen(onBackPressedDispatcher)
}
}
}
}
}
}
fun onOpenFolder(context: Context) {
val file = LaunchUtils.getBaseDirectory(context)
val clipboardManager =
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboardManager.setPrimaryClip(
ClipData.newPlainText(
context.getString(R.string.export_folder_tag),
file.path
)
)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
Toast.makeText(
context,
context.getString(R.string.text_copied),
Toast.LENGTH_SHORT
).show()
}
}
fun onOpenLogs(context: Context) {
val launchIntent = Intent(context, ApplicationLogsActivity::class.java)
context.startActivity(launchIntent)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
Column {
TopAppBar(
navigationIcon = {
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
Icon(
painterResource(R.drawable.icon_arrow_back),
contentDescription = stringResource(R.string.back_icon_alt)
)
}
},
title = {
Text(
stringResource(R.string.title_activity_developer_settings),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.topAppBarColors(MaterialTheme.colorScheme.background),
)
}
}
) { innerPadding ->
Column(
Modifier
.padding(innerPadding)
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
val developerMode by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DEVELOPER_MODE)
OptionsGroup {
SettingsCard(
title = stringResource(R.string.preference_developer_mode),
preferenceKey = PreferenceUtils.Key.DEVELOPER_MODE,
)
}
if (developerMode) {
OptionsGroup(title = stringResource(R.string.preference_category_gameplay)) {
SettingsStringCard(
title = stringResource(R.string.preference_launch_arguments_name),
dialogTitle = stringResource(R.string.preference_launch_arguments_set_title),
preferenceKey = PreferenceUtils.Key.LAUNCH_ARGUMENTS,
filterInput = { it.filter { c ->
// if only there was a better way to define this!
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890!@#$%^&*(){}<>[]?:;'\"~`-_+=\\| ".contains(c)
}}
)
/*
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
SettingsCard(
title = stringResource(R.string.preference_force_hrr),
preferenceKey = PreferenceUtils.Key.FORCE_HRR,
)
}
SettingsCard(
title = stringResource(R.string.preference_override_exceptions_name),
description = stringResource(R.string.preference_override_exceptions_description),
preferenceKey = PreferenceUtils.Key.CUSTOM_SYMBOL_LIST
)
*/
}
OptionsGroup(stringResource(R.string.preference_category_developer)) {
val context = LocalContext.current
OptionsButton(
title = stringResource(R.string.preferences_view_logs),
icon = {
Icon(
painterResource(R.drawable.icon_description),
contentDescription = null
)
},
onClick = { onOpenLogs(context) }
)
OptionsButton(
title = stringResource(R.string.preferences_copy_external_button),
description = LaunchUtils.getBaseDirectory(context).path,
onClick = { onOpenFolder(context) }
)
SettingsCard(
title = stringResource(R.string.preference_development_server_name),
description = stringResource(R.string.preference_development_server_description),
preferenceKey = PreferenceUtils.Key.DEVELOPER_SERVER,
) { enabled ->
if (enabled) {
} else {
}
}
}
OptionsGroup(title = stringResource(R.string.preference_category_updater)) {
SettingsSelectCard(
title = stringResource(R.string.preference_release_channel_tag_name),
dialogTitle = stringResource(R.string.preference_release_channel_select),
preferenceKey = PreferenceUtils.Key.RELEASE_CHANNEL_TAG,
options = linkedMapOf(
0 to stringResource(R.string.preference_release_channel_stable),
1 to stringResource(R.string.preference_release_channel_beta),
2 to stringResource(R.string.preference_release_channel_nightly),
)
)
}
OptionsGroup(title = stringResource(R.string.preference_category_testing)) {
SettingsCard(
title = stringResource(R.string.preference_enable_redesign),
preferenceKey = PreferenceUtils.Key.ENABLE_REDESIGN,
)
}
OptionsGroup(title = stringResource(R.string.preference_profiles)) {
ProfileCreateCard()
ProfileSelectCard()
}
}
Spacer(Modifier.height(4.dp))
}
}
}
@@ -0,0 +1,574 @@
package com.geode.launcher.preferences
import android.app.UiModeManager
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.provider.DocumentsContract
import android.view.WindowManager
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedDispatcher
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.geode.launcher.BuildConfig
import com.geode.launcher.R
import com.geode.launcher.UserDirectoryProvider
import com.geode.launcher.updater.ReleaseViewModel
import com.geode.launcher.ui.theme.GeodeLauncherTheme
import com.geode.launcher.ui.theme.LocalTheme
import com.geode.launcher.ui.theme.Theme
import com.geode.launcher.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 java.net.ConnectException
import java.net.UnknownHostException
import kotlin.math.roundToInt
class SettingsActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
val theme = Theme.fromInt(themeOption)
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
CompositionLocalProvider(LocalTheme provides theme) {
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
SettingsScreen(
onBackPressedDispatcher = onBackPressedDispatcher
)
}
}
}
}
}
}
fun onOpenFileManager(context: Context) {
// future thoughts: Intent.ACTION_VIEW may not work on some devices
// the "correct" solution is to go through a list of possible intents/applications
// don't feel like writing that behavior. this is good enough
val intent = Intent(Intent.ACTION_VIEW).apply {
data = DocumentsContract.buildRootUri(
"${context.packageName}.user",
UserDirectoryProvider.ROOT
)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
Intent.FLAG_GRANT_PREFIX_URI_PERMISSION or
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
)
}
try {
context.startActivity(intent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(
context,
context.getText(R.string.no_activity_found), Toast.LENGTH_SHORT
).show()
}
}
@Composable
fun UpdateIndicator(
snackbarHostState: SnackbarHostState,
updateStatus: ReleaseViewModel.ReleaseUIState
) {
val context = LocalContext.current
val resources = LocalResources.current
var enablePopup by remember { mutableStateOf(false) }
when (updateStatus) {
is ReleaseViewModel.ReleaseUIState.InUpdateCheck -> {
enablePopup = true
CircularProgressIndicator()
}
is ReleaseViewModel.ReleaseUIState.InDownload -> {
// is this the ideal design? idk
enablePopup = true
if (updateStatus.outOf != null) {
CircularProgressIndicator(
progress = {
val progress = updateStatus.downloaded / updateStatus.outOf.toDouble()
progress.toFloat()
},
)
} else {
CircularProgressIndicator()
}
}
else -> {}
}
LaunchedEffect(updateStatus) {
// only show popup if some progress was shown
if (!enablePopup) {
return@LaunchedEffect
}
when (updateStatus) {
is ReleaseViewModel.ReleaseUIState.Failure -> {
val message = when (updateStatus.exception) {
is UnknownHostException, is ConnectException ->
resources.getString(R.string.release_fetch_no_internet)
else -> resources.getString(R.string.preference_check_for_updates_failed)
}
snackbarHostState.showSnackbar(message)
}
is ReleaseViewModel.ReleaseUIState.Finished -> {
if (updateStatus.hasUpdated) {
snackbarHostState.showSnackbar(
resources.getString(R.string.preference_check_for_updates_success)
)
} else {
val gameVersion = GamePackageUtils.getGameVersionCodeOrNull(context.packageManager)
if (gameVersion != null && gameVersion < Constants.SUPPORTED_VERSION_CODE) {
snackbarHostState.showSnackbar(
resources.getString(R.string.launcher_game_update_required_short)
)
} else {
snackbarHostState.showSnackbar(
resources.getString(R.string.preference_check_for_updates_none_found)
)
}
}
}
else -> {}
}
}
}
fun updateTheme(context: Context, theme: Int) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
uiModeManager.setApplicationNightMode(
when (theme) {
1 -> UiModeManager.MODE_NIGHT_NO
2 -> UiModeManager.MODE_NIGHT_YES
else -> UiModeManager.MODE_NIGHT_CUSTOM
}
)
} else {
AppCompatDelegate.setDefaultNightMode(
when (theme) {
1 -> AppCompatDelegate.MODE_NIGHT_NO
2 -> AppCompatDelegate.MODE_NIGHT_YES
else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
)
}
}
fun onOpenDeveloperOptions(context: Context) {
val launchIntent = Intent(context, DeveloperSettingsActivity::class.java)
context.startActivity(launchIntent)
}
@Composable
fun DeveloperModeDialog(onDismiss: () -> Unit, onEnable: () -> Unit) {
AlertDialog(
icon = {
Icon(
painterResource(R.drawable.icon_warning),
contentDescription = null,
)
},
title = { Text(stringResource(R.string.preference_developer_options_dialog_title)) },
text = { Text(stringResource(R.string.preference_developer_mode_about)) },
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.message_box_cancel))
}
},
confirmButton = {
TextButton(onClick = onEnable) {
Text(stringResource(R.string.message_box_accept))
}
},
onDismissRequest = onDismiss
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onBackPressedDispatcher: OnBackPressedDispatcher?,
releaseViewModel: ReleaseViewModel = viewModel(factory = ReleaseViewModel.Factory)
) {
val context = LocalContext.current
val resources = LocalResources.current
val currentRelease by PreferenceUtils.useStringPreference(PreferenceUtils.Key.CURRENT_VERSION_TAG)
val updateStatus by releaseViewModel.uiState.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
var showUpdateInProgress by remember { mutableStateOf(false) }
if (showUpdateInProgress) {
LaunchedEffect(snackbarHostState) {
snackbarHostState.showSnackbar(
resources.getString(R.string.preference_check_for_updates_already_updating)
)
showUpdateInProgress = false
}
}
// fix theme transition by giving it the exact same animation as the top bar
val containerColor by animateColorAsState(
targetValue = MaterialTheme.colorScheme.background,
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
label = "background color"
)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
containerColor = containerColor,
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
},
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
Icon(
painterResource(R.drawable.icon_arrow_back),
contentDescription = stringResource(R.string.back_icon_alt)
)
}
},
title = {
Text(
stringResource(R.string.title_activity_settings),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.topAppBarColors(MaterialTheme.colorScheme.background),
)
},
content = { innerPadding ->
Column(
Modifier
.padding(innerPadding)
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
OptionsGroup(stringResource(R.string.preference_category_testing)) {
SettingsSelectCard(
title = stringResource(R.string.preference_theme_name),
dialogTitle = stringResource(R.string.preference_theme_select),
preferenceKey = PreferenceUtils.Key.THEME,
options = linkedMapOf(
0 to stringResource(R.string.preference_theme_default),
1 to stringResource(R.string.preference_theme_light),
2 to stringResource(R.string.preference_theme_dark),
),
extraSelectBehavior = { updateTheme(context, it) }
)
SettingsCard(
title = stringResource(R.string.preference_black_background_name),
preferenceKey = PreferenceUtils.Key.BLACK_BACKGROUND
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
SettingsCard(
title = stringResource(R.string.preference_disable_styles),
preferenceKey = PreferenceUtils.Key.DISABLE_USER_THEME
)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val currentIcon by PreferenceUtils.useStringPreference(PreferenceUtils.Key.SELECTED_ICON)
val iconName = remember(currentIcon) {
IconUtils.getIconDetails(
ApplicationIcon.fromId(currentIcon ?: "default")
).nameId
}
OptionsButton(
title = stringResource(R.string.preference_set_application_icon),
onClick = {
val launchIntent = Intent(context, ApplicationIconActivity::class.java)
context.startActivity(launchIntent)
},
description = stringResource(iconName),
displayInline = true,
)
}
OptionsButton(
title = stringResource(R.string.preferences_open_file_manager),
icon = {
Icon(
painterResource(R.drawable.icon_folder),
contentDescription = null
)
},
onClick = { onOpenFileManager(context) }
)
}
OptionsGroup(stringResource(R.string.preference_category_gameplay)) {
SettingsCard(
title = stringResource(R.string.preference_load_automatically_name),
description = stringResource(R.string.preference_load_automatically_description),
preferenceKey = PreferenceUtils.Key.LOAD_AUTOMATICALLY,
)
SettingsSelectCard(
title = stringResource(R.string.preference_display_mode_name),
dialogTitle = stringResource(R.string.preference_display_mode_select),
preferenceKey = PreferenceUtils.Key.DISPLAY_MODE,
options = buildMap {
put(0, stringResource(R.string.preference_display_mode_default))
put(1, stringResource(R.string.preference_display_mode_legacy))
// necessary api doesn't exist on older versions of android
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
put(2, stringResource(R.string.preference_display_mode_fullscreen))
}
}
)
val currentDisplayMode by PreferenceUtils.useIntPreference(PreferenceUtils.Key.DISPLAY_MODE)
if (currentDisplayMode == 1) {
SettingsStringSelectCard(
title = stringResource(R.string.preference_custom_aspect_ratio_name),
description = stringResource(R.string.preference_custom_aspect_ratio_description),
dialogTitle = stringResource(R.string.preference_custom_aspect_ratio_select),
preferenceKey = PreferenceUtils.Key.CUSTOM_ASPECT_RATIO,
options = linkedMapOf(
"16_9" to stringResource(R.string.preference_custom_aspect_ratio_16x9),
"16_10" to stringResource(R.string.preference_custom_aspect_ratio_16x10),
"4_3" to stringResource(R.string.preference_custom_aspect_ratio_4x3),
"1_1" to stringResource(R.string.preference_custom_aspect_ratio_1x1),
)
)
}
SettingsRangeCard(
title = stringResource(R.string.preference_screen_zoom_name),
description = stringResource(R.string.preference_screen_zoom_description),
dialogTitle = stringResource(R.string.preference_screen_zoom_select),
preferenceKey = PreferenceUtils.Key.SCREEN_ZOOM,
labelSuffix = "x",
range = 25..100,
scale = 100,
step = 5,
) {
SettingsCard(
title = stringResource(R.string.preference_screen_zoom_fit_name),
preferenceKey = PreferenceUtils.Key.SCREEN_ZOOM_FIT,
asCard = false
)
}
val maxFrameRate = remember {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
context.display.supportedModes.maxOf { it.refreshRate }
} else {
@Suppress("DEPRECATION")
(context.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay.refreshRate
}.roundToInt()
}
SettingsFPSCard(
title = stringResource(R.string.preference_limit_framerate_name),
dialogTitle = stringResource(R.string.preference_limit_framerate_select),
preferenceKey = PreferenceUtils.Key.LIMIT_FRAME_RATE,
maxFrameRate = maxFrameRate
)
}
OptionsGroup(stringResource(R.string.preference_category_updater)) {
SettingsCard(
title = stringResource(R.string.preference_update_automatically_name),
preferenceKey = PreferenceUtils.Key.UPDATE_AUTOMATICALLY,
)
OptionsCard(
title = {
OptionsTitle(
title = stringResource(R.string.preference_check_for_updates_button),
description = stringResource(
R.string.preference_check_for_updates_description,
currentRelease ?: "unknown"
)
)
},
modifier = Modifier
.clickable(
onClick = {
if (releaseViewModel.isInUpdate) {
showUpdateInProgress = true
} else {
releaseViewModel.runReleaseCheck(true)
}
},
role = Role.Button
)
) {
UpdateIndicator(snackbarHostState, updateStatus)
}
}
val developerModeEnabled by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DEVELOPER_MODE)
OptionsGroup(stringResource(R.string.preference_category_developer)) {
if (developerModeEnabled) {
OptionsButton(
title = stringResource(R.string.preference_open_developer_options),
icon = {
Icon(
painterResource(R.drawable.icon_data_object),
contentDescription = null
)
},
onClick = { onOpenDeveloperOptions(context) }
)
}
OptionsButton(
title = stringResource(R.string.preferences_view_crashes),
icon = {
Icon(
painterResource(R.drawable.icon_bug_report),
contentDescription = null
)
},
onClick = {
val launchIntent = Intent(context, CrashLogsActivity::class.java)
context.startActivity(launchIntent)
}
)
}
OptionsGroup(stringResource(R.string.preference_category_about)) {
var launcherButtonTapped by remember { mutableIntStateOf(0) }
var showDeveloperDialog by remember { mutableStateOf(false) }
OptionsButton(
stringResource(R.string.preference_launcher_version_name),
stringResource(R.string.preference_launcher_version_description, BuildConfig.VERSION_NAME),
displayInline = true
) {
launcherButtonTapped += 1
if (launcherButtonTapped >= 7 && !developerModeEnabled) {
showDeveloperDialog = true
launcherButtonTapped = 0
}
}
val context = LocalContext.current
if (showDeveloperDialog) {
DeveloperModeDialog(
onDismiss = {
showDeveloperDialog = false
},
onEnable = {
PreferenceUtils.get(context).setBoolean(PreferenceUtils.Key.DEVELOPER_MODE, true)
showDeveloperDialog = false
}
)
}
OptionsLabel(
stringResource(R.string.preference_loader_version_name),
stringResource(R.string.preference_loader_version_description, currentRelease ?: "unknown"),
displayInline = true
)
val gameInstalled = remember {
GamePackageUtils.isGameInstalled(context.packageManager)
}
if (gameInstalled) {
val gameVersion = remember {
GamePackageUtils.getUnifiedVersionName(context.packageManager)
}
val gameSource = remember {
resources.getString(when (GamePackageUtils.identifyGameSource(context.packageManager)) {
GamePackageUtils.GameSource.GOOGLE -> R.string.preference_game_source_google
GamePackageUtils.GameSource.AMAZON -> R.string.preference_game_source_amazon
else -> R.string.preference_game_source_unknown
})
}
OptionsLabel(
stringResource(R.string.preference_game_version_name),
stringResource(R.string.preference_game_version_description, gameVersion, gameSource),
displayInline = true
)
OptionsLabel(
stringResource(R.string.preference_architecture_name),
stringResource(R.string.preference_architecture_description, Build.MODEL, LaunchUtils.applicationArchitecture),
displayInline = true
)
}
}
Spacer(Modifier.height(4.dp))
}
}
)
}
@Preview(showSystemUi = true)
@Composable
fun DefaultPreview() {
GeodeLauncherTheme {
SettingsScreen(null)
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,24 @@
package com.geode.launcher.ui.theme
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import com.geode.launcher.R
val robotoMonoFamily = FontFamily(
Font(R.font.robotomono_medium, FontWeight.Medium),
Font(R.font.robotomono_regular, FontWeight.Normal)
)
val quicksandFamily = FontFamily(
Font(R.font.quicksand_semibold, FontWeight.SemiBold)
)
val launcherTitleStyle = TextStyle(
fontFamily = quicksandFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 32.sp,
letterSpacing = 0.sp
)
@@ -1,6 +1,5 @@
package com.geode.launcher.ui.theme package com.geode.launcher.ui.theme
import android.app.Activity
import android.os.Build import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -9,35 +8,20 @@ import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
private val DarkColorScheme = darkColorScheme( private val DarkColorScheme = darkColorScheme(
primary = Purple80, primary = Purple80,
secondary = PurpleGrey80, secondary = PurpleGrey80,
tertiary = Pink80 tertiary = Pink80
) )
private val LightColorScheme = lightColorScheme( private val LightColorScheme = lightColorScheme(
primary = Purple40, primary = Purple40,
secondary = PurpleGrey40, secondary = PurpleGrey40,
tertiary = Pink40 tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
) )
enum class Theme { enum class Theme {
@@ -1,11 +1,12 @@
package com.geode.launcher.updater package com.geode.launcher.updater
import com.geode.launcher.utils.LaunchUtils import com.geode.launcher.utils.LaunchUtils
import kotlinx.datetime.Instant import kotlin.time.Instant
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlin.time.ExperimentalTime
@Serializable @Serializable
data class Asset( data class Asset @OptIn(ExperimentalTime::class) constructor(
val url: String, val url: String,
val id: Int, val id: Int,
val name: String, val name: String,
@@ -16,7 +17,7 @@ data class Asset(
) )
@Serializable @Serializable
class Release( data class Release @OptIn(ExperimentalTime::class) constructor(
val url: String, val url: String,
val id: Int, val id: Int,
val targetCommitish: String, val targetCommitish: String,
@@ -24,40 +25,88 @@ class Release(
val createdAt: Instant, val createdAt: Instant,
val publishedAt: Instant, val publishedAt: Instant,
val assets: List<Asset>, val assets: List<Asset>,
val htmlUrl: String val htmlUrl: String,
) { val body: String?,
fun getDescription(): String { val name: String?,
if (tagName == "nightly") { )
@Serializable
data class LoaderVersion @OptIn(ExperimentalTime::class) constructor(
val tag: String,
val version: String,
val createdAt: Instant,
val commitHash: String,
val prerelease: Boolean
)
@Serializable
data class LoaderPayload<T>(
val payload: T?,
val error: String
)
data class DownloadableAsset(val url: String, val filename: String, val size: Long? = null)
abstract class Downloadable {
abstract fun getDescription(): String
abstract fun getDescriptor(): Long
abstract fun getDownload(): DownloadableAsset?
}
class DownloadableGitHubLoaderRelease(private val release: Release) : Downloadable() {
override fun getDescription(): String {
if (release.tagName == "nightly") {
// get the commit from the assets // get the commit from the assets
// otherwise, a separate request is needed to get the hash (ew) // otherwise, a separate request is needed to get the hash (ew)
val asset = assets.first() val asset = release.assets.first()
val commit = asset.name.substring(6..12) val commit = asset.name.substring(6..12)
return "nightly-$commit" return "nightly-$commit"
} }
return tagName return release.tagName
} }
fun getDescriptor(): Long { @OptIn(ExperimentalTime::class)
return createdAt.epochSeconds override fun getDescriptor(): Long {
return release.createdAt.epochSeconds
} }
fun getGeodeDownload(): Asset? { private fun getGitHubDownload(): Asset? {
// try to find an asset that matches the architecture first // try to find an asset that matches the architecture first
val platform = LaunchUtils.platformName val platform = LaunchUtils.platformName
val releaseSuffix = "$platform.zip" val releaseSuffix = "$platform.zip"
return assets.find { return release.assets.find {
it.name.endsWith(releaseSuffix) it.name.endsWith(releaseSuffix)
} }
} }
fun getLauncherDownload(): Asset? { override fun getDownload(): DownloadableAsset? {
val download = getGitHubDownload() ?: return null
return DownloadableAsset(
url = download.browserDownloadUrl,
filename = download.name,
size = download.size.toLong()
)
}
}
class DownloadableLauncherRelease(val release: Release) : Downloadable() {
override fun getDescription(): String {
return release.tagName
}
@OptIn(ExperimentalTime::class)
override fun getDescriptor(): Long {
return release.createdAt.epochSeconds
}
private fun getGitHubDownload(): Asset? {
val platform = LaunchUtils.platformName val platform = LaunchUtils.platformName
val use32BitPlatform = platform == "android32" val use32BitPlatform = platform == "android32"
return assets.find { asset -> return release.assets.find { asset ->
/* you know it's good when you pull out the truth table /* you know it's good when you pull out the truth table
* u32bp | contains | found * u32bp | contains | found
* 1 | 1 | 1 * 1 | 1 | 1
@@ -70,4 +119,32 @@ class Release(
(asset.name.contains("android32")) == use32BitPlatform (asset.name.contains("android32")) == use32BitPlatform
} }
} }
override fun getDownload(): DownloadableAsset? {
val download = getGitHubDownload() ?: return null
return DownloadableAsset(
url = download.browserDownloadUrl,
filename = download.name,
size = download.size.toLong()
)
}
}
class DownloadableLoaderRelease(private val version: LoaderVersion) : Downloadable() {
override fun getDescription(): String {
return version.tag
}
@OptIn(ExperimentalTime::class)
override fun getDescriptor(): Long {
return version.createdAt.epochSeconds
}
override fun getDownload(): DownloadableAsset? {
val filename = "geode-${version.tag}-${LaunchUtils.platformName}.zip"
return DownloadableAsset(
url = "https://github.com/geode-sdk/geode/releases/download/${version.tag}/$filename",
filename = filename
)
}
} }
@@ -4,6 +4,7 @@ import android.content.Context
import android.util.Log import android.util.Log
import com.geode.launcher.BuildConfig import com.geode.launcher.BuildConfig
import com.geode.launcher.utils.DownloadUtils import com.geode.launcher.utils.DownloadUtils
import com.geode.launcher.utils.GamePackageUtils
import com.geode.launcher.utils.LaunchUtils import com.geode.launcher.utils.LaunchUtils
import com.geode.launcher.utils.PreferenceUtils import com.geode.launcher.utils.PreferenceUtils
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@@ -22,6 +23,9 @@ import okio.source
import java.io.File import java.io.File
import java.io.InterruptedIOException import java.io.InterruptedIOException
private const val TAG_LATEST = "latest"
private const val TAG_BETA = "prerelease"
/** /**
* Singleton to manage Geode updates, from update checking to downloading. * Singleton to manage Geode updates, from update checking to downloading.
*/ */
@@ -57,7 +61,7 @@ class ReleaseManager private constructor(
sealed class ReleaseManagerState { sealed class ReleaseManagerState {
data object InUpdateCheck : ReleaseManagerState() data object InUpdateCheck : ReleaseManagerState()
data class Failure(val exception: Exception) : ReleaseManagerState() data class Failure(val exception: Exception) : ReleaseManagerState()
data class InDownload(val downloaded: Long, val outOf: Long) : ReleaseManagerState() data class InDownload(val downloaded: Long, val outOf: Long?) : ReleaseManagerState()
data class Finished(val hasUpdated: Boolean = false) : ReleaseManagerState() data class Finished(val hasUpdated: Boolean = false) : ReleaseManagerState()
} }
@@ -78,7 +82,7 @@ class ReleaseManager private constructor(
val isInUpdate: Boolean val isInUpdate: Boolean
get() = _uiState.value !is ReleaseManagerState.Failure && _uiState.value !is ReleaseManagerState.Finished get() = _uiState.value !is ReleaseManagerState.Failure && _uiState.value !is ReleaseManagerState.Finished
private val _availableLauncherUpdate = MutableStateFlow<Release?>(null) private val _availableLauncherUpdate = MutableStateFlow<DownloadableLauncherRelease?>(null)
val availableLauncherUpdate = _availableLauncherUpdate.asStateFlow() val availableLauncherUpdate = _availableLauncherUpdate.asStateFlow()
private fun sendError(e: Exception) { private fun sendError(e: Exception) {
@@ -91,24 +95,72 @@ class ReleaseManager private constructor(
} }
} }
fun dismissLauncherUpdate() { private fun mapSelectedReleaseToTag(): String {
val update = availableLauncherUpdate.value ?: return
val sharedPreferences = PreferenceUtils.get(applicationContext) val sharedPreferences = PreferenceUtils.get(applicationContext)
sharedPreferences.setString(PreferenceUtils.Key.LAST_DISMISSED_UPDATE, update.tagName) val releaseChannel = sharedPreferences.getInt(PreferenceUtils.Key.RELEASE_CHANNEL_TAG)
_availableLauncherUpdate.value = null return when (releaseChannel) {
2 -> "nightly"
1 -> TAG_BETA
else -> TAG_LATEST
}
} }
private suspend fun getLatestRelease(): Release? { private fun getBestReleaseForGameVersion(gameVersion: Long): String? = when {
val sharedPreferences = PreferenceUtils.get(applicationContext) gameVersion >= 40L -> mapSelectedReleaseToTag()
val useNightly = sharedPreferences.getBoolean(PreferenceUtils.Key.RELEASE_CHANNEL) gameVersion == 39L -> "v3.9.3"
gameVersion == 38L -> "v2.0.0-beta.27"
return releaseRepository.getLatestGeodeRelease(useNightly) gameVersion == 37L -> "v2.0.0-beta.4"
else -> null
} }
private suspend fun performUpdate(release: Release) { private suspend fun getLatestRelease(): Downloadable? {
val releaseAsset = release.getGeodeDownload() if (!GamePackageUtils.isGameInstalled(applicationContext.packageManager)) {
return null
}
val gameVersion = GamePackageUtils.getGameVersionCode(applicationContext.packageManager)
val preferences = PreferenceUtils.get(applicationContext)
val targetTag = preferences.getInt(PreferenceUtils.Key.RELEASE_CHANNEL_TAG)
return when (targetTag) {
2 -> releaseRepository.getReleaseByTag("nightly")
1 -> releaseRepository.getLatestIndexRelease(gameVersion, true)
else -> releaseRepository.getLatestIndexRelease(gameVersion, false)
}
}
private suspend fun downloadLauncherUpdate(release: Downloadable) {
val download = release.getDownload() ?: return
val outputDirectory = LaunchUtils.getBaseDirectory(applicationContext)
val outputFile = File(outputDirectory, download.filename)
if (outputFile.exists()) {
// only download the apk once
return
}
_uiState.value = ReleaseManagerState.InDownload(0, download.size)
try {
val fileStream = DownloadUtils.downloadStream(
httpClient,
download.url
) { progress, outOf ->
_uiState.value = ReleaseManagerState.InDownload(progress, outOf)
}
fileStream.copyTo(outputFile.outputStream())
} catch (e: Exception) {
sendError(e)
return
}
}
private suspend fun performUpdate(release: Downloadable) {
val releaseAsset = release.getDownload()
if (releaseAsset == null) { if (releaseAsset == null) {
val noAssetException = Exception("missing Android download") val noAssetException = Exception("missing Android download")
_uiState.value = ReleaseManagerState.Failure(noAssetException) _uiState.value = ReleaseManagerState.Failure(noAssetException)
@@ -117,47 +169,62 @@ class ReleaseManager private constructor(
} }
// set an initial download size // set an initial download size
_uiState.value = ReleaseManagerState.InDownload(0, releaseAsset.size.toLong()) _uiState.value = ReleaseManagerState.InDownload(0, releaseAsset.size)
val outputFile = getTempFile()
// clone the file instance as renameTo may move the original file
val tempFilePath = outputFile.path
try { try {
val fileStream = DownloadUtils.downloadStream( val fileStream = DownloadUtils.downloadStream(
httpClient, httpClient,
releaseAsset.browserDownloadUrl releaseAsset.url
) { progress, outOf -> ) { progress, outOf ->
_uiState.value = ReleaseManagerState.InDownload(progress, outOf) _uiState.value = ReleaseManagerState.InDownload(progress, outOf)
} }
outputFile.parentFile?.mkdirs()
val geodeFile = getGeodeOutputPath() val geodeFile = getGeodeOutputPath()
DownloadUtils.extractFileFromZipStream(
fileStream,
outputFile.outputStream(),
geodeFile.name
)
// work around a permission issue from adb push // work around a permission issue from adb push
if (geodeFile.exists()) { if (geodeFile.exists()) {
geodeFile.delete() geodeFile.delete()
} }
geodeFile.parentFile?.mkdirs() outputFile.renameTo(geodeFile)
DownloadUtils.extractFileFromZipStream(
fileStream,
geodeFile.outputStream(),
geodeFile.name
)
} catch (e: Exception) { } catch (e: Exception) {
sendError(e) sendError(e)
return return
} finally {
val tempFileClone = File(tempFilePath)
try {
if (tempFileClone.exists()) tempFileClone.delete()
} catch (_: Exception) { }
} }
downloadLauncherUpdateIfNecessary()
// extraction performed // extraction performed
updatePreferences(release) updatePreferences(release)
_uiState.value = ReleaseManagerState.Finished(true) _uiState.value = ReleaseManagerState.Finished(true)
} }
private fun fileWasExternallyModified(): Boolean { private fun fileWasExternallyModified(modifiedOnly: Boolean = false): Boolean {
// make sure geode is still here. just in case
val geodeFile = getGeodeOutputPath() val geodeFile = getGeodeOutputPath()
if (!geodeFile.exists()) { if (!geodeFile.exists()) {
return false return !modifiedOnly
} }
val sharedPreferences = PreferenceUtils.get(applicationContext) val sharedPreferences = PreferenceUtils.get(applicationContext)
val originalFileHash = sharedPreferences.getString(PreferenceUtils.Key.CURRENT_RELEASE_MODIFIED) val originalFileHash = sharedPreferences.getString(PreferenceUtils.Key.CURRENT_RELEASE_MODIFIED)
?: return false ?: return false
@@ -166,20 +233,18 @@ class ReleaseManager private constructor(
return originalFileHash != currentFileHash return originalFileHash != currentFileHash
} }
private fun checkLauncherUpdate(launcherUpdate: Release) { private fun checkLauncherUpdate(launcherUpdate: DownloadableLauncherRelease) {
val sharedPreferences = PreferenceUtils.get(applicationContext) if (launcherUpdate.release.tagName != BuildConfig.VERSION_NAME) {
val lastDismissedVersion = sharedPreferences.getString(PreferenceUtils.Key.LAST_DISMISSED_UPDATE)
if (!lastDismissedVersion.isNullOrEmpty() && launcherUpdate.tagName == lastDismissedVersion) {
return
}
if (launcherUpdate.tagName != BuildConfig.VERSION_NAME) {
_availableLauncherUpdate.value = launcherUpdate _availableLauncherUpdate.value = launcherUpdate
} }
} }
private suspend fun checkForNewRelease(allowOverwriting: Boolean = false) { private suspend fun downloadLauncherUpdateIfNecessary() {
val update = _availableLauncherUpdate.value ?: return
downloadLauncherUpdate(update)
}
private suspend fun checkForNewRelease(isManual: Boolean = false) {
val release = try { val release = try {
getLatestRelease() getLatestRelease()
} catch (e: Exception) { } catch (e: Exception) {
@@ -199,6 +264,8 @@ class ReleaseManager private constructor(
} }
if (release == null) { if (release == null) {
downloadLauncherUpdateIfNecessary()
_uiState.value = ReleaseManagerState.Finished() _uiState.value = ReleaseManagerState.Finished()
return return
} }
@@ -208,17 +275,20 @@ class ReleaseManager private constructor(
val currentVersion = sharedPreferences.getLong(PreferenceUtils.Key.CURRENT_VERSION_TIMESTAMP) val currentVersion = sharedPreferences.getLong(PreferenceUtils.Key.CURRENT_VERSION_TIMESTAMP)
val latestVersion = release.getDescriptor() val latestVersion = release.getDescriptor()
// make sure geode is still here. just in case
val geodeFile = getGeodeOutputPath()
// check if an update is needed // check if an update is needed
if (latestVersion == currentVersion && geodeFile.exists()) { if (latestVersion == currentVersion && !fileWasExternallyModified()) {
downloadLauncherUpdateIfNecessary()
_uiState.value = ReleaseManagerState.Finished() _uiState.value = ReleaseManagerState.Finished()
return return
} }
val allowOverwriting = !sharedPreferences.getBoolean(PreferenceUtils.Key.DEVELOPER_MODE) || isManual
// check if the file was externally modified // check if the file was externally modified
if (!allowOverwriting && fileWasExternallyModified()) { if (!allowOverwriting && fileWasExternallyModified(true)) {
downloadLauncherUpdateIfNecessary()
sendError(UpdateException(UpdateException.Reason.EXTERNAL_FILE_IN_USE)) sendError(UpdateException(UpdateException.Reason.EXTERNAL_FILE_IN_USE))
return return
} }
@@ -226,7 +296,7 @@ class ReleaseManager private constructor(
performUpdate(release) performUpdate(release)
} }
private fun updatePreferences(release: Release) { private fun updatePreferences(release: Downloadable) {
val sharedPreferences = PreferenceUtils.get(applicationContext) val sharedPreferences = PreferenceUtils.get(applicationContext)
sharedPreferences.setString( sharedPreferences.setString(
@@ -256,6 +326,15 @@ class ReleaseManager private constructor(
return File(geodeDirectory, geodeName) return File(geodeDirectory, geodeName)
} }
private fun getTempFile(): File {
val geodeName = LaunchUtils.geodeFilename
val geodeDirectory = LaunchUtils.getBaseDirectory(applicationContext)
val tempFile = File.createTempFile(geodeName, null, geodeDirectory)
return tempFile
}
/** /**
* Cancels the current update job. * Cancels the current update job.
*/ */
@@ -281,4 +360,4 @@ class ReleaseManager private constructor(
return _uiState.asStateFlow() return _uiState.asStateFlow()
} }
} }
@@ -1,18 +1,20 @@
package com.geode.launcher.updater package com.geode.launcher.updater
import com.geode.launcher.utils.DownloadUtils.executeCoroutine import com.geode.launcher.utils.DownloadUtils.executeCoroutine
import kotlinx.datetime.Clock import kotlin.time.Clock
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.Instant import kotlin.time.Instant
import kotlinx.datetime.until import kotlinx.datetime.until
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNamingStrategy import kotlinx.serialization.json.JsonNamingStrategy
import kotlinx.serialization.json.okio.decodeFromBufferedSource import kotlinx.serialization.json.okio.decodeFromBufferedSource
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import java.io.IOException import java.io.IOException
import java.net.URL import java.net.URL
import kotlin.time.ExperimentalTime
class ReleaseRepository(private val httpClient: OkHttpClient) { class ReleaseRepository(private val httpClient: OkHttpClient) {
companion object { companion object {
@@ -22,37 +24,37 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
private const val GITHUB_RATELIMIT_REMAINING = "x-ratelimit-remaining" private const val GITHUB_RATELIMIT_REMAINING = "x-ratelimit-remaining"
private const val GITHUB_RATELIMIT_RESET = "x-ratelimit-reset" private const val GITHUB_RATELIMIT_RESET = "x-ratelimit-reset"
private const val GEODE_API_BASE = "https://api.geode-sdk.org/v1"
} }
suspend fun getLatestLauncherRelease(): Release? { suspend fun getLatestLauncherRelease(): DownloadableLauncherRelease? {
val releasePath = "$GITHUB_API_BASE/repos/geode-sdk/android-launcher/releases/latest" val releasePath = "$GITHUB_API_BASE/repos/geode-sdk/android-launcher/releases/latest"
val url = URL(releasePath) val url = URL(releasePath)
return getReleaseByUrl(url) return getReleaseByUrl(url)?.let(::DownloadableLauncherRelease)
} }
suspend fun getLatestGeodeRelease(isNightly: Boolean = false): Release? { suspend fun getLatestGeodeRelease(): DownloadableGitHubLoaderRelease? {
val geodeBaseUrl = "$GITHUB_API_BASE/repos/geode-sdk/geode/releases" val releasePath = "$GITHUB_API_BASE/repos/geode-sdk/geode/releases/latest"
val releasePath = if (isNightly) "$geodeBaseUrl/tags/nightly"
else "$geodeBaseUrl/latest"
val url = URL(releasePath) val url = URL(releasePath)
return getReleaseByUrl(url) return getReleaseByUrl(url)?.let(::DownloadableGitHubLoaderRelease)
} }
private fun generateRateLimitMessage(resetTime: Instant): String { suspend fun getReleaseByTag(tag: String): DownloadableGitHubLoaderRelease? {
val currentTime = Clock.System.now() val releasePath = "$GITHUB_API_BASE/repos/geode-sdk/geode/releases/tags/$tag"
val resetDelay = currentTime.until(resetTime, DateTimeUnit.SECOND) val url = URL(releasePath)
val formattedWait = "${resetDelay / 60}m" return getReleaseByUrl(url)?.let(::DownloadableGitHubLoaderRelease)
return "api ratelimit reached, try again in ${formattedWait}"
} }
@OptIn(ExperimentalSerializationApi::class) @OptIn(ExperimentalSerializationApi::class, ExperimentalTime::class)
private suspend fun getReleaseByUrl(url: URL): Release? { suspend fun getLatestGeodePreRelease(): DownloadableGitHubLoaderRelease? {
val releasesUrl = "$GITHUB_API_BASE/repos/geode-sdk/geode/releases?per_page=2"
val url = URL(releasesUrl)
val request = Request.Builder() val request = Request.Builder()
.url(url) .url(url)
.addHeader("Accept", "application/json") .addHeader("Accept", "application/json")
@@ -69,11 +71,12 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
ignoreUnknownKeys = true ignoreUnknownKeys = true
} }
val release = format.decodeFromBufferedSource<Release>( val release = format.decodeFromBufferedSource<List<Release>>(
response.body!!.source() response.body!!.source()
) )
release (release.find { it.tagName != "nightly" })
?.let(::DownloadableGitHubLoaderRelease)
} }
403 -> { 403 -> {
// determine if the error code is a ratelimit // determine if the error code is a ratelimit
@@ -101,4 +104,106 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
} }
} }
} }
@OptIn(ExperimentalTime::class)
private fun generateRateLimitMessage(resetTime: Instant): String {
val currentTime = Clock.System.now()
val resetDelay = currentTime.until(resetTime, DateTimeUnit.SECOND)
val formattedWait = "${resetDelay / 60}m"
return "api ratelimit reached, try again in ${formattedWait}"
}
@OptIn(ExperimentalSerializationApi::class, ExperimentalTime::class)
private suspend fun getReleaseByUrl(url: URL): Release? {
val request = Request.Builder()
.url(url)
.addHeader("Accept", "application/json")
.addHeader(GITHUB_API_HEADER, GITHUB_API_VERSION)
.build()
val call = httpClient.newCall(request)
val response = call.executeCoroutine()
return when (response.code) {
200 -> {
val format = Json {
namingStrategy = JsonNamingStrategy.SnakeCase
ignoreUnknownKeys = true
}
val release = format.decodeFromBufferedSource<Release>(
response.body!!.source()
)
release
}
403 -> {
// determine if the error code is a ratelimit
// (github docs say it sends 429 too, but haven't seen that)
val limitRemaining = response.headers[GITHUB_RATELIMIT_REMAINING]?.toInt()
val limitReset = response.headers[GITHUB_RATELIMIT_RESET]?.toLong()
if (limitRemaining == 0 && limitReset != null) {
// handle ratelimit with a custom error
// there's also a retry-after header but again, haven't seen
val resetTime = Instant.fromEpochSeconds(limitReset, 0L)
val msg = generateRateLimitMessage(resetTime)
throw IOException(msg)
}
throw IOException("response 403: ${response.body!!.string()}")
}
404 -> {
null
}
else -> {
throw IOException("unknown response ${response.code}")
}
}
}
@OptIn(ExperimentalSerializationApi::class)
suspend fun getLatestIndexRelease(gd: Long, prerelease: Boolean = false): DownloadableLoaderRelease? {
val url = "$GEODE_API_BASE/loader/versions/latest".toHttpUrlOrNull()!!
.newBuilder()
.addQueryParameter("gd", gd.toString())
.addQueryParameter("platform", "android")
.addQueryParameter("prerelease", prerelease.toString())
.build()
val request = Request.Builder()
.url(url)
.addHeader("Accept", "application/json")
.build()
val call = httpClient.newCall(request)
val response = call.executeCoroutine()
return when (response.code) {
200 -> {
val format = Json {
namingStrategy = JsonNamingStrategy.SnakeCase
ignoreUnknownKeys = true
}
val body = format.decodeFromBufferedSource<LoaderPayload<LoaderVersion>>(
response.body!!.source()
)
val release = body.payload ?: throw IOException("index error: ${body.error}")
DownloadableLoaderRelease(release)
}
404 -> {
null
}
else -> {
throw IOException("unknown response ${response.code}")
}
}
}
} }
@@ -30,7 +30,7 @@ class ReleaseViewModel(private val application: Application): ViewModel() {
sealed class ReleaseUIState { sealed class ReleaseUIState {
data object InUpdateCheck : ReleaseUIState() data object InUpdateCheck : ReleaseUIState()
data class Failure(val exception: Exception) : ReleaseUIState() data class Failure(val exception: Exception) : ReleaseUIState()
data class InDownload(val downloaded: Long, val outOf: Long) : ReleaseUIState() data class InDownload(val downloaded: Long, val outOf: Long?) : ReleaseUIState()
data class Finished(val hasUpdated: Boolean = false) : ReleaseUIState() data class Finished(val hasUpdated: Boolean = false) : ReleaseUIState()
data class Cancelled(val isCancelling: Boolean = false) : ReleaseUIState() data class Cancelled(val isCancelling: Boolean = false) : ReleaseUIState()
@@ -98,10 +98,4 @@ class ReleaseViewModel(private val application: Application): ViewModel() {
syncUiState(releaseFlow) syncUiState(releaseFlow)
} }
} }
fun dismissLauncherUpdate() {
viewModelScope.launch(Dispatchers.IO) {
ReleaseManager.get(application).dismissLauncherUpdate()
}
}
} }
@@ -0,0 +1,46 @@
package com.geode.launcher.utils
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.unit.sp
@Composable
fun LabelledText(label: String, icon: @Composable (() -> Unit)? = null, modifier: Modifier = Modifier) {
if (icon != null) {
val iconId = "inlineIcon"
val paddingId = "paddingIcon"
val labelText = buildAnnotatedString {
appendInlineContent(iconId, "[x]")
appendInlineContent(paddingId, " ")
append(label)
}
val inlineContent = mapOf(
Pair(iconId, InlineTextContent(
Placeholder(
width = 24.sp,
height = 24.sp,
placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter
)
) {
icon()
}),
Pair(paddingId, InlineTextContent(Placeholder(width = 6.sp, height = 24.sp, placeholderVerticalAlign = PlaceholderVerticalAlign.Center)) {
Spacer(modifier = Modifier.fillMaxWidth())
})
)
Text(text = labelText, inlineContent = inlineContent, modifier = modifier)
} else {
Text(label, modifier = modifier)
}
}
@@ -13,6 +13,14 @@ object Constants {
@SuppressLint("SdCardPath") @SuppressLint("SdCardPath")
const val GJ_DATA_DIR = "/data/data/${PACKAGE_NAME}" const val GJ_DATA_DIR = "/data/data/${PACKAGE_NAME}"
const val SUPPORTED_VERSION_CODE = 38L // anything below this version code is blocked from opening the game
const val SUPPORTED_VERSION_STRING = "2.205" 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
// anything above this version code will show a warning when starting the game
const val SUPPORTED_VERSION_CODE = 40L
const val SUPPORTED_VERSION_STRING = "2.2.143"
} }
@@ -2,26 +2,71 @@ package com.geode.launcher.utils
import android.content.Context import android.content.Context
import android.widget.FrameLayout import android.widget.FrameLayout
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.min
class ConstrainedFrameLayout(context: Context) : FrameLayout(context) { class ConstrainedFrameLayout(context: Context) : FrameLayout(context) {
// avoid running the aspect ratio fix when we don't need it // avoid running the aspect ratio fix when we don't need it
var aspectRatio: Float? = null; var aspectRatio: Float? = null
var zoom: Float? = null
var fitZoom: Boolean = false
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
if (aspectRatio != null) { var paddingX = 0.0f
var paddingY = 0.0f
val currentZoom = zoom
if (currentZoom != null) {
val width = MeasureSpec.getSize(widthMeasureSpec) val width = MeasureSpec.getSize(widthMeasureSpec)
val height = MeasureSpec.getSize(heightMeasureSpec) val height = MeasureSpec.getSize(heightMeasureSpec)
if (width == 0 || height == 0) { if (width == 0 || height == 0) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec) super.onMeasure(widthMeasureSpec, heightMeasureSpec)
return
} }
val calculatedWidth = height * (aspectRatio ?: 1.0f); val calculatedWidth = width * currentZoom
val padding = ((width - calculatedWidth) / 2).toInt() val calculatedHeight = height * currentZoom
// ignore paddings where the screen ends up looking odd paddingX = (width - calculatedWidth)/2
if (padding > 0) { paddingY = (height - calculatedHeight)/2
setPadding(padding, 0, padding, 0) }
val currentAspectRatio = aspectRatio
if (currentAspectRatio != null) {
val width = MeasureSpec.getSize(widthMeasureSpec)
val height = MeasureSpec.getSize(heightMeasureSpec)
if (width == 0 || height == 0) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
return
} }
val calculatedWidth = (height - paddingY * 2) * currentAspectRatio
val padding = (width - calculatedWidth) / 2
paddingX = padding
}
if (paddingX > 0.0 || paddingY > 0.0) {
if (fitZoom) {
val width = MeasureSpec.getSize(widthMeasureSpec)
val height = MeasureSpec.getSize(heightMeasureSpec)
val calculatedHeight = (height - paddingY * 2)
val calculatedWidth = (width - paddingX * 2)
val zoomFactor = min(height / calculatedHeight, width / calculatedWidth)
scaleX = zoomFactor
scaleY = zoomFactor
}
setPadding(
ceil(paddingX).toInt(),
ceil(paddingY).toInt(),
floor(paddingX).toInt(),
floor(paddingY).toInt()
)
} }
super.onMeasure( super.onMeasure(
@@ -20,6 +20,9 @@ object GamePackageUtils {
} }
} }
fun showDownloadBadge(packageManager: PackageManager) =
!isGameInstalled(packageManager) || !identifyGameLegitimacy(packageManager)
fun getGameVersionCode(packageManager: PackageManager): Long { fun getGameVersionCode(packageManager: PackageManager): Long {
val game = packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0) val game = packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0)
@@ -31,14 +34,24 @@ object GamePackageUtils {
} }
} }
fun getGameVersionCodeOrNull(packageManager: PackageManager): Long? {
return try {
getGameVersionCode(packageManager)
} catch (e: PackageManager.NameNotFoundException) {
null
}
}
fun getGameVersionString(packageManager: PackageManager): String { fun getGameVersionString(packageManager: PackageManager): String {
val game = packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0) val game = packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0)
return game.versionName return game.versionName ?: "0.000"
} }
private val gameVersionMap = mapOf( private val gameVersionMap = mapOf(
37L to "2.200", 37L to "2.200",
38L to "2.205" 38L to "2.205",
39L to "2.206",
40L to "2.2074" // ok
) )
/** /**
@@ -52,8 +65,8 @@ object GamePackageUtils {
fun detectAbiMismatch(context: Context, packageInfo: PackageInfo, loadException: Error): Boolean { fun detectAbiMismatch(context: Context, packageInfo: PackageInfo, loadException: Error): Boolean {
val abi = LaunchUtils.applicationArchitecture val abi = LaunchUtils.applicationArchitecture
val isExtracted = val isExtracted =
packageInfo.applicationInfo.flags and ApplicationInfo.FLAG_EXTRACT_NATIVE_LIBS == ApplicationInfo.FLAG_EXTRACT_NATIVE_LIBS packageInfo.applicationInfo!!.flags and ApplicationInfo.FLAG_EXTRACT_NATIVE_LIBS == ApplicationInfo.FLAG_EXTRACT_NATIVE_LIBS
val isSplit = (packageInfo.applicationInfo.splitSourceDirs?.size ?: 0) > 1 val isSplit = (packageInfo.applicationInfo?.splitSourceDirs?.size ?: 0) > 1
val metadata = val metadata =
"Geometry Dash metadata:\nSplit sources: $isSplit\nExtracted libraries: $isExtracted\nLauncher architecture: $abi" "Geometry Dash metadata:\nSplit sources: $isSplit\nExtracted libraries: $isExtracted\nLauncher architecture: $abi"
@@ -73,7 +86,7 @@ object GamePackageUtils {
val oppositeArchitecture = if (abi == "arm64-v8a") "armeabi-v7a" else "arm64-v8a" val oppositeArchitecture = if (abi == "arm64-v8a") "armeabi-v7a" else "arm64-v8a"
// detect issues using native library directory (only works on extracted libraries) // detect issues using native library directory (only works on extracted libraries)
val nativeLibraryDir = packageInfo.applicationInfo.nativeLibraryDir val nativeLibraryDir = packageInfo.applicationInfo!!.nativeLibraryDir
if (nativeLibraryDir.contains(oppositeArchitecture)) { if (nativeLibraryDir.contains(oppositeArchitecture)) {
return true return true
@@ -104,31 +117,47 @@ object GamePackageUtils {
val clazz = assetManager.javaClass val clazz = assetManager.javaClass
val aspMethod = clazz.getDeclaredMethod("addAssetPath", String::class.java) val aspMethod = clazz.getDeclaredMethod("addAssetPath", String::class.java)
aspMethod.invoke(assetManager, packageInfo.applicationInfo.sourceDir) aspMethod.invoke(assetManager, packageInfo.applicationInfo?.sourceDir)
packageInfo.applicationInfo.splitSourceDirs?.forEach { packageInfo.applicationInfo?.splitSourceDirs?.forEach {
aspMethod.invoke(assetManager, it) aspMethod.invoke(assetManager, it)
} }
} }
private const val GAME_CERTIFICATE_HASH = "f5e7d8284d72c461a5f022d0cf755df101c3bb1e69cbe241bc1aef2cc5610a43" private const val GAME_CERTIFICATE_HASH = "f5e7d8284d72c461a5f022d0cf755df101c3bb1e69cbe241bc1aef2cc5610a43"
private const val AMAZON_CERTIFICATE_HASH = "1f228081e4d66e006887d4ba0f3d40e96a80c758b84231f11cd5c1c9aef6048f"
private fun validateCertificate(certificate: ByteArray): Boolean = private fun validateCertificate(certificate: ByteArray): GameSource = when (val hash = certificate.toByteString().sha256().hex()) {
certificate.toByteString().sha256().hex() == GAME_CERTIFICATE_HASH GAME_CERTIFICATE_HASH -> GameSource.GOOGLE
AMAZON_CERTIFICATE_HASH -> GameSource.AMAZON
else -> {
println("Encountered certificate hash: $hash")
GameSource.UNKNOWN
}
}
fun identifyGameLegitimacy(packageManager: PackageManager): Boolean { enum class GameSource {
UNKNOWN, GOOGLE, AMAZON
}
fun identifyGameSource(packageManager: PackageManager): GameSource {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
val certificates = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { val certificates = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val game = packageManager.getPackageInfo(Constants.PACKAGE_NAME, PackageManager.GET_SIGNING_CERTIFICATES) val game = packageManager.getPackageInfo(Constants.PACKAGE_NAME, PackageManager.GET_SIGNING_CERTIFICATES)
val signingInfo = game.signingInfo val signingInfo = game.signingInfo
signingInfo.signingCertificateHistory signingInfo?.signingCertificateHistory ?: return GameSource.UNKNOWN
} else { } else {
val game = packageManager.getPackageInfo(Constants.PACKAGE_NAME, PackageManager.GET_SIGNATURES) val game = packageManager.getPackageInfo(Constants.PACKAGE_NAME, PackageManager.GET_SIGNATURES)
game.signatures game.signatures
} }
return certificates.any { return certificates?.map {
validateCertificate(it.toByteArray()) validateCertificate(it.toByteArray())
} }?.firstOrNull {
it != GameSource.UNKNOWN
} ?: GameSource.UNKNOWN
} }
fun identifyGameLegitimacy(packageManager: PackageManager) =
identifyGameSource(packageManager) != GameSource.UNKNOWN
} }
@@ -10,6 +10,9 @@ import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment import android.os.Environment
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import android.provider.DocumentsContract import android.provider.DocumentsContract
import android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION import android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION
import android.util.Log import android.util.Log
@@ -18,7 +21,9 @@ import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.Keep import androidx.annotation.Keep
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.geode.launcher.BuildConfig import com.geode.launcher.BuildConfig
import com.geode.launcher.R import com.geode.launcher.R
@@ -34,6 +39,8 @@ import kotlin.system.exitProcess
@Suppress("unused", "KotlinJniMissingFunction") @Suppress("unused", "KotlinJniMissingFunction")
object GeodeUtils { object GeodeUtils {
private lateinit var activity: WeakReference<AppCompatActivity> private lateinit var activity: WeakReference<AppCompatActivity>
var handleSafeArea: Boolean = false
private lateinit var openFileResultLauncher: ActivityResultLauncher<GeodeOpenFileActivityResult.OpenFileParams> private lateinit var openFileResultLauncher: ActivityResultLauncher<GeodeOpenFileActivityResult.OpenFileParams>
private lateinit var openDirectoryResultLauncher: ActivityResultLauncher<Uri?> private lateinit var openDirectoryResultLauncher: ActivityResultLauncher<Uri?>
private lateinit var openFilesResultLauncher: ActivityResultLauncher<GeodeOpenFilesActivityResult.OpenFileParams> private lateinit var openFilesResultLauncher: ActivityResultLauncher<GeodeOpenFilesActivityResult.OpenFileParams>
@@ -172,7 +179,7 @@ object GeodeUtils {
val manager = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val manager = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = manager.primaryClip val clip = manager.primaryClip
if (clip != null && clip.itemCount > 0) { if (clip != null && clip.itemCount > 0) {
return clip.getItemAt(0).text.toString() return clip.getItemAt(0).coerceToText(this).toString()
} }
} }
return "" return ""
@@ -373,11 +380,6 @@ object GeodeUtils {
return activity.filesDir.canonicalPath return activity.filesDir.canonicalPath
} }
private val gameVersionMap = mapOf(
37L to "2.200",
38L to "2.205"
)
@JvmStatic @JvmStatic
fun getGameVersion(): String { fun getGameVersion(): String {
// these versions should be aligned to windows releases, not what android says // these versions should be aligned to windows releases, not what android says
@@ -388,6 +390,14 @@ object GeodeUtils {
return "" return ""
} }
/**
* Returns the current version code of the launcher.
*/
@JvmStatic
fun getLauncherVersion(): Int {
return BuildConfig.VERSION_CODE
}
fun isGeodeUri(uri: Uri): Boolean { fun isGeodeUri(uri: Uri): Boolean {
return "com.geode.launcher.user" == uri.authority return "com.geode.launcher.user" == uri.authority
} }
@@ -443,6 +453,68 @@ object GeodeUtils {
private external fun permissionCallback(granted: Boolean) private external fun permissionCallback(granted: Boolean)
internal fun getVibrator(): Vibrator? = activity.get()?.run {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val manager = getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
manager.defaultVibrator
} else {
@Suppress("DEPRECATION")
getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
}
}
// vibration
/**
* If vibration is supported on the device.
*/
@JvmStatic
fun vibrateSupported(): Boolean = getVibrator()?.hasVibrator() == true
/**
* Vibrates for the given amount of time.
*/
@JvmStatic
fun vibrate(ms: Long) {
if (ms == 0L) {
getVibrator()?.cancel()
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
getVibrator()?.vibrate(VibrationEffect.createOneShot(ms, VibrationEffect.DEFAULT_AMPLITUDE))
} else {
@Suppress("DEPRECATION")
getVibrator()?.vibrate(ms)
}
}
/**
* Vibrates with the given pattern.
* @see android.os.VibrationEffect.createWaveform
*/
@JvmStatic
fun vibratePattern(pattern: LongArray, repeat: Int) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
getVibrator()?.vibrate(VibrationEffect.createWaveform(pattern, repeat))
} else {
@Suppress("DEPRECATION")
getVibrator()?.vibrate(pattern, repeat)
}
}
@JvmStatic
fun getScreenInsets(): IntArray? {
if (!handleSafeArea || Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
return null
}
return activity.get()?.run {
val displayCutout = window.decorView.rootWindowInsets?.displayCutout ?: return null
intArrayOf(displayCutout.safeInsetLeft, displayCutout.safeInsetBottom, displayCutout.safeInsetRight, displayCutout.safeInsetTop)
}
}
const val ARGUMENT_SAFE_MODE = "--geode:safe-mode" const val ARGUMENT_SAFE_MODE = "--geode:safe-mode"
private var additionalLaunchArguments = arrayListOf<String>() private var additionalLaunchArguments = arrayListOf<String>()
@@ -474,6 +546,7 @@ object GeodeUtils {
const val CAPABILITY_EXTENDED_INPUT = "extended_input" const val CAPABILITY_EXTENDED_INPUT = "extended_input"
const val CAPABILITY_TIMESTAMP_INPUT = "timestamp_inputs" const val CAPABILITY_TIMESTAMP_INPUT = "timestamp_inputs"
const val CAPABILITY_INTERNAL_CALLBACKS = "internal_callbacks_v1"
private var capabilityListener: WeakReference<CapabilityListener?> = WeakReference(null) private var capabilityListener: WeakReference<CapabilityListener?> = WeakReference(null)
@@ -481,13 +554,26 @@ object GeodeUtils {
capabilityListener = WeakReference(listener) capabilityListener = WeakReference(listener)
} }
/**
* Reports that the game client supports a feature.
* These features typically require callbacks into the game, so ensure that the required methods are registered first.
*/
@JvmStatic @JvmStatic
fun reportPlatformCapability(capability: String?): Boolean { fun reportPlatformCapability(capability: String?): Boolean {
if (capability.isNullOrEmpty()) { if (capability.isNullOrEmpty()) {
return false return false
} }
return capabilityListener.get()?.onCapabilityAdded(capability) ?: false return capabilityListener.get()?.onCapabilityAdded(capability) == true
}
@JvmStatic
fun openWebview(url: String) {
val activity = activity.get() ?: return
CustomTabsIntent.Builder()
.setShowTitle(true)
.build()
.launchUrl(activity, url.toUri())
} }
external fun nativeKeyUp(keyCode: Int, modifiers: Int) external fun nativeKeyUp(keyCode: Int, modifiers: Int)
@@ -495,6 +581,11 @@ object GeodeUtils {
external fun nativeActionScroll(scrollX: Float, scrollY: Float) external fun nativeActionScroll(scrollX: Float, scrollY: Float)
external fun resizeSurface(width: Int, height: Int) external fun resizeSurface(width: Int, height: Int)
// represents the timestamp of the next input callback, in nanoseconds (most events don't send it, but it's there) /**
* Gives the timestamp of the next input callback, in nanoseconds.
* Enable this by sending the "timestamp_inputs" platform capability.
* @see reportPlatformCapability
*/
external fun setNextInputTimestamp(timestamp: Long) external fun setNextInputTimestamp(timestamp: Long)
external fun setNextInputTimestampInternal(timestamp: Long)
} }
@@ -0,0 +1,100 @@
package com.geode.launcher.utils
import android.graphics.drawable.AdaptiveIconDrawable
import android.os.Build
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.painterResource
import androidx.core.content.res.ResourcesCompat
import androidx.core.graphics.drawable.toBitmap
import com.geode.launcher.R
// https://gist.github.com/tkuenneth/ddf598663f041dc79960cda503d14448?permalink_comment_id=4660486#gistcomment-4660486
@Composable
fun adaptiveIconPainterResource(@DrawableRes id: Int): Painter {
val context = LocalContext.current
val res = LocalResources.current
val theme = context.theme
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val adaptiveIcon = remember(id) {
ResourcesCompat.getDrawable(res, id, theme) as? AdaptiveIconDrawable
}
if (adaptiveIcon != null) {
remember(id) {
BitmapPainter(adaptiveIcon.toBitmap().asImageBitmap())
}
} else {
painterResource(id)
}
} else {
painterResource(id)
}
}
enum class ApplicationIcon {
DEFAULT,
GEODE,
PRIDE,
TRANS;
companion object {
fun fromId(id: String) = when (id) {
"default" -> ApplicationIcon.DEFAULT
"geode" -> ApplicationIcon.GEODE
"pride" -> ApplicationIcon.PRIDE
"trans" -> ApplicationIcon.TRANS
else -> ApplicationIcon.DEFAULT
}
}
fun toId() = when (this) {
ApplicationIcon.PRIDE -> "pride"
ApplicationIcon.GEODE -> "geode"
ApplicationIcon.DEFAULT -> "default"
ApplicationIcon.TRANS -> "trans"
}
}
data class ApplicationIconDetails(
val id: String,
val component: String,
@param:DrawableRes val iconId: Int,
@param:StringRes val nameId: Int,
)
object IconUtils {
fun getIconDetails(id: ApplicationIcon) = when (id) {
ApplicationIcon.DEFAULT -> ApplicationIconDetails(
id = id.toId(),
component = "MainActivity",
iconId = R.mipmap.ic_launcher,
nameId = R.string.application_icon_default
)
ApplicationIcon.GEODE -> ApplicationIconDetails(
id = id.toId(),
component = "MainActivityGeode",
iconId = R.mipmap.ic_launcher_geode,
nameId = R.string.application_icon_geode
)
ApplicationIcon.PRIDE -> ApplicationIconDetails(
id = id.toId(),
component = "MainActivityPride",
iconId = R.mipmap.ic_launcher_pride,
nameId = R.string.application_icon_pride
)
ApplicationIcon.TRANS -> ApplicationIconDetails(
id = id.toId(),
component = "MainActivityTrans",
iconId = R.mipmap.ic_launcher_trans,
nameId = R.string.application_icon_trans
)
}
}
@@ -3,6 +3,7 @@ package com.geode.launcher.utils
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import java.io.File import java.io.File
import java.io.Serializable
object LaunchUtils { object LaunchUtils {
// supposedly CPU_ABI returns the current arch for the running application // supposedly CPU_ABI returns the current arch for the running application
@@ -46,7 +47,7 @@ object LaunchUtils {
/** /**
* Returns the directory that Geode/the game should base itself off of. * Returns the directory that Geode/the game should base itself off of.
*/ */
fun getBaseDirectory(context: Context): File { fun getBaseDirectory(context: Context, ignoreProfile: Boolean = false): File {
// deprecated, but seems to be the best choice of directory (i forced mat to test it) // deprecated, but seems to be the best choice of directory (i forced mat to test it)
// also, is getting the first item the correct choice here?? what do they mean // also, is getting the first item the correct choice here?? what do they mean
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
@@ -57,6 +58,14 @@ object LaunchUtils {
val noMediaPath = File(dir, ".nomedia") val noMediaPath = File(dir, ".nomedia")
noMediaPath.createNewFile() noMediaPath.createNewFile()
val currentProfile = ProfileManager.get(context).getCurrentProfile()
if (currentProfile != null && !ignoreProfile) {
val profile = File(dir, "profiles/$currentProfile/")
profile.mkdirs()
return profile
}
return dir return dir
} }
@@ -64,9 +73,9 @@ object LaunchUtils {
return File(getBaseDirectory(context), "save") return File(getBaseDirectory(context), "save")
} }
private const val CRASH_INDICATOR_NAME = "lastSessionDidCrash" const val CRASH_INDICATOR_NAME = "lastSessionDidCrash"
private fun getCrashDirectory(context: Context): File { fun getCrashDirectory(context: Context): File {
val base = getBaseDirectory(context) val base = getBaseDirectory(context)
return File(base, "game/geode/crashlogs/") return File(base, "game/geode/crashlogs/")
} }
@@ -92,9 +101,10 @@ object LaunchUtils {
return crashIndicatorFile.exists() return crashIndicatorFile.exists()
} }
enum class LauncherError { enum class LauncherError : Serializable {
LINKER_NEEDS_64BIT, LINKER_NEEDS_64BIT,
LINKER_NEEDS_32BIT, LINKER_NEEDS_32BIT,
LINKER_FAILED_STL,
LINKER_FAILED, LINKER_FAILED,
GENERIC, GENERIC,
CRASHED; CRASHED;
@@ -8,7 +8,7 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.core.content.edit import androidx.core.content.edit
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
@@ -20,8 +20,6 @@ import com.geode.launcher.BuildConfig
*/ */
class PreferenceUtils(private val sharedPreferences: SharedPreferences) { class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
companion object { companion object {
private const val FILE_KEY = "GeodeLauncherPreferencesFileKey"
@Composable @Composable
fun useBooleanPreference(preferenceKey: Key, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current): MutableState<Boolean> { fun useBooleanPreference(preferenceKey: Key, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current): MutableState<Boolean> {
return usePreference( return usePreference(
@@ -70,11 +68,14 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
preferenceSet: (PreferenceUtils, Key, T) -> Unit preferenceSet: (PreferenceUtils, Key, T) -> Unit
): MutableState<T> { ): MutableState<T> {
val context = LocalContext.current val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences(FILE_KEY, Context.MODE_PRIVATE)
val currentProfile = remember { ProfileManager.get(context).getCurrentFileKey() }
val sharedPreferences = context.getSharedPreferences(currentProfile, Context.MODE_PRIVATE)
val preferences = get(sharedPreferences) val preferences = get(sharedPreferences)
val preferenceValue = remember { // inferring the type causes an internal compiler error. lol
val preferenceValue: MutableState<T> = remember {
val state = mutableStateOf(preferenceGet(preferences, preferenceKey)) val state = mutableStateOf(preferenceGet(preferences, preferenceKey))
object : MutableState<T> by state { object : MutableState<T> by state {
override var value: T override var value: T
@@ -113,7 +114,8 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
} }
fun get(context: Context): PreferenceUtils { fun get(context: Context): PreferenceUtils {
val sharedPreferences = context.getSharedPreferences(FILE_KEY, Context.MODE_PRIVATE) val currentFileKey = ProfileManager.get(context).getCurrentFileKey()
val sharedPreferences = context.getSharedPreferences(currentFileKey, Context.MODE_PRIVATE)
return get(sharedPreferences) return get(sharedPreferences)
} }
@@ -136,22 +138,42 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
LAUNCH_ARGUMENTS, LAUNCH_ARGUMENTS,
LIMIT_ASPECT_RATIO, LIMIT_ASPECT_RATIO,
DISPLAY_MODE, DISPLAY_MODE,
FORCE_HRR FORCE_HRR,
ENABLE_REDESIGN,
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,
DEVELOPER_SERVER,
} }
private fun defaultValueForBooleanKey(key: Key): Boolean { private fun defaultValueForBooleanKey(key: Key): Boolean {
return when (key) { return when (key) {
Key.UPDATE_AUTOMATICALLY, Key.FORCE_HRR -> true Key.UPDATE_AUTOMATICALLY, Key.FORCE_HRR -> true
Key.RELEASE_CHANNEL -> BuildConfig.DEBUG Key.DEVELOPER_MODE -> BuildConfig.DEBUG
else -> false else -> false
} }
} }
private fun defaultValueForIntKey(key: Key) = when (key) { private fun defaultValueForIntKey(key: Key) = when (key) {
Key.DISPLAY_MODE -> if (this.getBoolean(Key.LIMIT_ASPECT_RATIO)) 1 else 0 Key.DISPLAY_MODE -> if (this.getBoolean(Key.LIMIT_ASPECT_RATIO)) 1 else 0
Key.SCREEN_ZOOM -> 100
// people wanted a reset on nightly anyways, so this is a good excuse to do so
else -> 0 else -> 0
} }
private fun defaultValueForStringKey(key: Key) = when (key) {
Key.CUSTOM_ASPECT_RATIO -> "16_9"
Key.SELECTED_ICON -> "default"
else -> null
}
private fun keyToName(key: Key): String { private fun keyToName(key: Key): String {
return when (key) { return when (key) {
Key.LOAD_AUTOMATICALLY -> "PreferenceLoadAutomatically" Key.LOAD_AUTOMATICALLY -> "PreferenceLoadAutomatically"
@@ -168,6 +190,18 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
Key.LIMIT_ASPECT_RATIO -> "PreferenceLimitAspectRatio" Key.LIMIT_ASPECT_RATIO -> "PreferenceLimitAspectRatio"
Key.DISPLAY_MODE -> "PreferenceDisplayMode" Key.DISPLAY_MODE -> "PreferenceDisplayMode"
Key.FORCE_HRR -> "PreferenceForceHighRefreshRate" Key.FORCE_HRR -> "PreferenceForceHighRefreshRate"
Key.ENABLE_REDESIGN -> "PreferenceEnableRedesign"
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"
Key.LIMIT_FRAME_RATE -> "PreferenceLimitFrameRate"
Key.DISABLE_USER_THEME -> "PreferenceDisableUserTheme"
Key.SELECTED_ICON -> "PreferenceSelectedIcon"
Key.DEVELOPER_SERVER -> "PreferenceDeveloperServer"
} }
} }
@@ -198,7 +232,7 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
fun getString(key: Key): String? { fun getString(key: Key): String? {
val keyName = keyToName(key) val keyName = keyToName(key)
return sharedPreferences.getString(keyName, null) return sharedPreferences.getString(keyName, defaultValueForStringKey(key))
} }
fun setString(key: Key, value: String?) { fun setString(key: Key, value: String?) {
@@ -0,0 +1,133 @@
package com.geode.launcher.utils
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
private const val FILE_KEY = "GeodeLauncherPreferencesFileKey"
private const val PROFILES_FILE_KEY = "GeodeLauncherProfiles"
private const val PROFILE_LIST_SAVE_KEY = "profiles"
private const val PROFILE_CURRENT_SAVE_KEY = "currentProfile"
@Serializable
data class Profile(val path: String, val name: String)
class ProfileManager {
private val applicationContext: Context
private val preferences: SharedPreferences
companion object {
private lateinit var managerInstance: ProfileManager
fun get(context: Context): ProfileManager {
if (!Companion::managerInstance.isInitialized) {
val applicationContext = context.applicationContext
managerInstance = ProfileManager(applicationContext)
}
return managerInstance
}
}
constructor(applicationContext: Context) {
this.applicationContext = applicationContext
this.preferences = applicationContext.getSharedPreferences(PROFILES_FILE_KEY, Context.MODE_PRIVATE)
_storedProfiles.value = getProfiles()
}
private val _storedProfiles = MutableStateFlow<List<Profile>>(emptyList())
val storedProfiles = _storedProfiles.asStateFlow()
fun getCurrentProfile(): String? {
return preferences.getString(PROFILE_CURRENT_SAVE_KEY, null)
.takeUnless { it.isNullOrEmpty() }
}
fun getCurrentFileKey(): String {
val currentProfile = getCurrentProfile()
?: return FILE_KEY
return "${FILE_KEY}_${currentProfile}"
}
fun getProfile(path: String): Profile? {
val profilesSet = preferences.getStringSet(PROFILE_LIST_SAVE_KEY, null)
return profilesSet?.firstNotNullOfOrNull {
Json.decodeFromString<Profile>(it).takeIf { it.path == path }
}
}
/**
* Store a profile into the save, removing any duplicate profiles beforehand
*/
fun storeProfile(profile: Profile) {
val profilesSet = preferences.getStringSet(PROFILE_LIST_SAVE_KEY, null) ?: emptySet<String>()
val removedList = profilesSet.filterTo(LinkedHashSet()) {
val saveProfile = Json.decodeFromString<Profile>(it)
saveProfile.path != profile.path
}
removedList.add(Json.encodeToString(profile))
preferences.edit {
putStringSet(PROFILE_LIST_SAVE_KEY, removedList)
}
_storedProfiles.value = getProfiles()
}
fun deleteProfile(path: String) {
val profilesSet = preferences.getStringSet(PROFILE_LIST_SAVE_KEY, null) ?: emptySet<String>()
val removedList = profilesSet.filterTo(LinkedHashSet()) {
val saveProfile = Json.decodeFromString<Profile>(it)
saveProfile.path != path
}
preferences.edit {
putStringSet(PROFILE_LIST_SAVE_KEY, removedList)
}
_storedProfiles.value = getProfiles()
}
fun deleteProfiles(paths: List<String>) {
val profilesSet = preferences.getStringSet(PROFILE_LIST_SAVE_KEY, null) ?: emptySet<String>()
val removedList = profilesSet.filterTo(LinkedHashSet()) {
val saveProfile = Json.decodeFromString<Profile>(it)
!paths.contains(saveProfile.path)
}
preferences.edit(commit = true) {
putStringSet(PROFILE_LIST_SAVE_KEY, removedList)
}
_storedProfiles.value = getProfiles()
}
fun setCurrentProfile(profile: String?) {
preferences.edit(commit = true) {
if (profile.isNullOrEmpty()) {
remove(PROFILE_CURRENT_SAVE_KEY)
} else {
putString(PROFILE_CURRENT_SAVE_KEY, profile)
}
}
}
fun getProfiles(): List<Profile> {
val profilesSet = preferences.getStringSet(PROFILE_LIST_SAVE_KEY, null) ?: emptySet<String>()
return profilesSet.map {
Json.decodeFromString<Profile>(it)
}
}
}
@@ -1,7 +1,7 @@
package com.geode.launcher.utils package com.geode.launcher.utils
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
@@ -0,0 +1,102 @@
/****************************************************************************
Copyright (c) 2010-2011 cocos2d-x.org
http://www.cocos2d-x.org
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
****************************************************************************/
package org.cocos2dx.lib
import android.content.Context
import android.content.res.Configuration
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.os.Build
import android.view.Surface
import android.view.WindowManager
import java.lang.ref.WeakReference
import kotlin.math.roundToInt
class Cocos2dxAccelerometer : SensorEventListener {
companion object {
@JvmStatic
external fun onSensorChanged(pX: Float, pY: Float, pZ: Float, pTimestamp: Long)
}
constructor(context: Context) {
mContext = WeakReference(context)
mSensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
mAccelerometer = mSensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
val display = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
context.display
} else {
@Suppress("DEPRECATION")
(context.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay
}
this.mNaturalOrientation = display.rotation
}
private val mContext: WeakReference<Context>
private val mSensorManager: SensorManager
private val mAccelerometer: Sensor?
private val mNaturalOrientation: Int
fun enable() {
this.mSensorManager.registerListener(this, this.mAccelerometer, SensorManager.SENSOR_DELAY_GAME)
}
fun setInterval(interval: Float) {
this.mSensorManager.registerListener(this, this.mAccelerometer, (interval * 100000).roundToInt())
}
fun disable() {
this.mSensorManager.unregisterListener(this)
}
override fun onSensorChanged(pSensorEvent: SensorEvent) {
if (pSensorEvent.sensor.type != Sensor.TYPE_ACCELEROMETER) {
return
}
var (x, y, z) = pSensorEvent.values
/*
* Because the axes are not swapped when the device's screen orientation
* changes. So we should swap it here. In tablets such as Motorola Xoom,
* the default orientation is landscape, so should consider this.
*/
val orientation = this.mContext.get()!!.resources.configuration.orientation
if ((orientation == Configuration.ORIENTATION_LANDSCAPE) && (this.mNaturalOrientation != Surface.ROTATION_0)) {
val tmp = x
x = -y
y = tmp
} else if ((orientation == Configuration.ORIENTATION_PORTRAIT) && (this.mNaturalOrientation != Surface.ROTATION_0)) {
val tmp = x
x = y
y = -tmp
}
Cocos2dxGLSurfaceView.queueAccelerometer(x, y, z, pSensorEvent.timestamp)
}
override fun onAccuracyChanged(pSensor: Sensor, pAccuracy: Int) {}
}
@@ -1,18 +0,0 @@
package org.cocos2dx.lib
import android.content.Context
import android.view.KeyEvent
import androidx.appcompat.widget.AppCompatEditText
class Cocos2dxEditText(context: Context) : AppCompatEditText(context) {
var cocos2dxGLSurfaceView: Cocos2dxGLSurfaceView? = null
override fun onKeyDown(keyCode: Int, keyEvent: KeyEvent): Boolean {
super.onKeyDown(keyCode, keyEvent)
if (keyCode != KeyEvent.KEYCODE_BACK) {
return true
}
cocos2dxGLSurfaceView?.requestFocus()
return true
}
}
@@ -1,5 +1,6 @@
package org.cocos2dx.lib package org.cocos2dx.lib
import android.app.Activity
import android.content.Context import android.content.Context
import android.opengl.GLSurfaceView import android.opengl.GLSurfaceView
import android.os.Build import android.os.Build
@@ -12,6 +13,7 @@ import android.view.MotionEvent
import android.view.Surface import android.view.Surface
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.appcompat.widget.AppCompatEditText
import com.customRobTop.BaseRobTopActivity import com.customRobTop.BaseRobTopActivity
import com.geode.launcher.utils.GeodeUtils import com.geode.launcher.utils.GeodeUtils
@@ -39,6 +41,11 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
msg.what = HANDLER_CLOSE_IME_KEYBOARD msg.what = HANDLER_CLOSE_IME_KEYBOARD
handler.sendMessage(msg) handler.sendMessage(msg)
} }
fun queueAccelerometer(x: Float, y: Float, z: Float, timestamp: Long) =
cocos2dxGLSurfaceView.queueEvent {
Cocos2dxAccelerometer.onSensorChanged(x, y, z, timestamp)
}
} }
private lateinit var cocos2dxRenderer: Cocos2dxRenderer private lateinit var cocos2dxRenderer: Cocos2dxRenderer
@@ -49,22 +56,21 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
} }
var sendTimestampEvents = false var sendTimestampEvents = false
var sendInternalTimestampEvents = false
var manualBackEvents = false
var cocos2dxEditText: Cocos2dxEditText? = null var cocos2dxEditText: AppCompatEditText? = null
set(value) { set(value) {
field = value field = value
field?.setOnEditorActionListener(cocos2dxTextInputWrapper) field?.setOnEditorActionListener(cocos2dxTextInputWrapper)
field?.cocos2dxGLSurfaceView = this
requestFocus() requestFocus()
} }
fun initView() { fun initView() {
setEGLContextClientVersion(2) setEGLContextClientVersion(2)
isFocusableInTouchMode = true isFocusableInTouchMode = true
// if (Build.VERSION.SDK_INT >= 11) {
preserveEGLContextOnPause = true preserveEGLContextOnPause = true
// }
cocos2dxGLSurfaceView = this cocos2dxGLSurfaceView = this
cocos2dxTextInputWrapper = Cocos2dxTextInputWrapper(this) cocos2dxTextInputWrapper = Cocos2dxTextInputWrapper(this)
Cocos2dxGLSurfaceView.handler = object : Handler(Looper.getMainLooper()) { Cocos2dxGLSurfaceView.handler = object : Handler(Looper.getMainLooper()) {
@@ -117,23 +123,42 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
@RequiresApi(Build.VERSION_CODES.R) @RequiresApi(Build.VERSION_CODES.R)
fun updateRefreshRate() { fun updateRefreshRate() {
val maxRefreshRate = display.supportedModes.maxBy { it.refreshRate } val chosenDisplay = display.supportedModes?.maxByOrNull { it.refreshRate }
holder.surface.setFrameRate( if (chosenDisplay == null) {
maxRefreshRate.refreshRate, println("updateRefreshRate failed to find a display to maximize refresh rate...")
Surface.FRAME_RATE_COMPATIBILITY_DEFAULT return
) }
val chosenRefreshRate = chosenDisplay.refreshRate
println("updateRefreshRate: selecting refresh rate of ${chosenDisplay.refreshRate} (display ${chosenDisplay.modeId})")
holder.surface.setFrameRate(chosenRefreshRate, Surface.FRAME_RATE_COMPATIBILITY_DEFAULT)
if (isAttachedToWindow) {
(context as Activity).window.attributes.preferredRefreshRate = chosenRefreshRate
}
} }
override fun onPause() { override fun onPause() {
queueEvent { cocos2dxRenderer.handleOnPause() } queueEvent { cocos2dxRenderer.handleOnPause() }
renderMode = RENDERMODE_WHEN_DIRTY
super.onPause() super.onPause()
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
renderMode = RENDERMODE_CONTINUOUSLY
queueEvent { cocos2dxRenderer.handleOnResume() } queueEvent { cocos2dxRenderer.handleOnResume() }
} }
private fun sendNextEventTimestamp(timestamp: Long) {
if (sendTimestampEvents)
GeodeUtils.setNextInputTimestamp(timestamp)
if (sendInternalTimestampEvents)
GeodeUtils.setNextInputTimestampInternal(timestamp)
}
override fun onTouchEvent(motionEvent: MotionEvent): Boolean { override fun onTouchEvent(motionEvent: MotionEvent): Boolean {
val pointerNumber = motionEvent.pointerCount val pointerNumber = motionEvent.pointerCount
val ids = IntArray(pointerNumber) val ids = IntArray(pointerNumber)
@@ -153,9 +178,7 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
val xDown = xs[0] val xDown = xs[0]
val f = ys[0] val f = ys[0]
queueEvent { queueEvent {
if (sendTimestampEvents) sendNextEventTimestamp(timestamp)
GeodeUtils.setNextInputTimestamp(timestamp)
cocos2dxRenderer.handleActionDown(idDown, xDown, f) cocos2dxRenderer.handleActionDown(idDown, xDown, f)
} }
true true
@@ -165,27 +188,21 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
val f2 = xs[0] val f2 = xs[0]
val f3 = ys[0] val f3 = ys[0]
queueEvent { queueEvent {
if (sendTimestampEvents) sendNextEventTimestamp(timestamp)
GeodeUtils.setNextInputTimestamp(timestamp)
cocos2dxRenderer.handleActionUp(idUp, f2, f3) cocos2dxRenderer.handleActionUp(idUp, f2, f3)
} }
true true
} }
MotionEvent.ACTION_MOVE -> { MotionEvent.ACTION_MOVE -> {
queueEvent { queueEvent {
if (sendTimestampEvents) sendNextEventTimestamp(timestamp)
GeodeUtils.setNextInputTimestamp(timestamp)
cocos2dxRenderer.handleActionMove(ids, xs, ys) cocos2dxRenderer.handleActionMove(ids, xs, ys)
} }
true true
} }
MotionEvent.ACTION_CANCEL -> { MotionEvent.ACTION_CANCEL -> {
queueEvent { queueEvent {
if (sendTimestampEvents) sendNextEventTimestamp(timestamp)
GeodeUtils.setNextInputTimestamp(timestamp)
cocos2dxRenderer.handleActionCancel(ids, xs, ys) cocos2dxRenderer.handleActionCancel(ids, xs, ys)
} }
true true
@@ -196,9 +213,7 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
val xPointerDown = motionEvent.getX(indexPointerDown) val xPointerDown = motionEvent.getX(indexPointerDown)
val y = motionEvent.getY(indexPointerDown) val y = motionEvent.getY(indexPointerDown)
queueEvent { queueEvent {
if (sendTimestampEvents) sendNextEventTimestamp(timestamp)
GeodeUtils.setNextInputTimestamp(timestamp)
cocos2dxRenderer.handleActionDown(idPointerDown, xPointerDown, y) cocos2dxRenderer.handleActionDown(idPointerDown, xPointerDown, y)
} }
true true
@@ -209,9 +224,7 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
val xPointerUp = motionEvent.getX(indexPointUp) val xPointerUp = motionEvent.getX(indexPointUp)
val y2 = motionEvent.getY(indexPointUp) val y2 = motionEvent.getY(indexPointUp)
queueEvent { queueEvent {
if (sendTimestampEvents) sendNextEventTimestamp(timestamp)
GeodeUtils.setNextInputTimestamp(timestamp)
cocos2dxRenderer.handleActionUp(idPointerUp, xPointerUp, y2) cocos2dxRenderer.handleActionUp(idPointerUp, xPointerUp, y2)
} }
true true
@@ -244,6 +257,28 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
} }
} }
fun sendKeyBack() {
if (cocos2dxEditText?.isFocused == true) {
requestFocus()
}
if (BaseRobTopActivity.blockBackButton) {
return
}
if (useKeyboardEvents) {
queueEvent {
cocos2dxRenderer.handleKeyDown(KeyEvent.KEYCODE_BACK)
}
} else {
val currentTime = System.nanoTime()
queueEvent {
sendNextEventTimestamp(currentTime)
GeodeUtils.nativeKeyDown(KeyEvent.KEYCODE_BACK, 0, false)
}
}
}
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
if (!useKeyboardEvents) { if (!useKeyboardEvents) {
return legacyKeyDown(keyCode, event) return legacyKeyDown(keyCode, event)
@@ -260,9 +295,7 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
} }
queueEvent { queueEvent {
if (sendTimestampEvents) sendNextEventTimestamp(event.eventTime * MS_TO_NS)
GeodeUtils.setNextInputTimestamp(event.eventTime * MS_TO_NS)
GeodeUtils.nativeKeyDown(keyCode, event.modifiers, event.repeatCount != 0) GeodeUtils.nativeKeyDown(keyCode, event.modifiers, event.repeatCount != 0)
} }
true true
@@ -285,9 +318,7 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
} }
queueEvent { queueEvent {
if (sendTimestampEvents) sendNextEventTimestamp(event.eventTime * MS_TO_NS)
GeodeUtils.setNextInputTimestamp(event.eventTime * MS_TO_NS)
GeodeUtils.nativeKeyUp(keyCode, event.modifiers) GeodeUtils.nativeKeyUp(keyCode, event.modifiers)
} }
@@ -308,9 +339,7 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
event.eventTimeNanos else event.eventTime * MS_TO_NS event.eventTimeNanos else event.eventTime * MS_TO_NS
queueEvent { queueEvent {
if (sendTimestampEvents) sendNextEventTimestamp(timestamp)
GeodeUtils.setNextInputTimestamp(timestamp)
GeodeUtils.nativeActionScroll(scrollX, scrollY) GeodeUtils.nativeActionScroll(scrollX, scrollY)
} }
@@ -2,6 +2,7 @@ package org.cocos2dx.lib
import android.content.Context import android.content.Context
import android.content.pm.ApplicationInfo import android.content.pm.ApplicationInfo
import android.content.res.AssetManager
import android.os.Process import android.os.Process
import com.customRobTop.BaseRobTopActivity import com.customRobTop.BaseRobTopActivity
import com.geode.launcher.utils.LaunchUtils import com.geode.launcher.utils.LaunchUtils
@@ -11,9 +12,9 @@ import com.geode.launcher.utils.LaunchUtils
object Cocos2dxHelper { object Cocos2dxHelper {
// private val sCocos2dMusic: Cocos2dxMusic? = null // private val sCocos2dMusic: Cocos2dxMusic? = null
// private val sCocos2dSound: Cocos2dxSound? = null // private val sCocos2dSound: Cocos2dxSound? = null
// private val sAssetManager: AssetManager? = null private var sAssetManager: AssetManager? = null
// private val sCocos2dxAccelerometer: Cocos2dxAccelerometer? = null private var sCocos2dxAccelerometer: Cocos2dxAccelerometer? = null
// private val sAccelerometerEnabled = false private var sAccelerometerEnabled = false
private var packageName: String? = null private var packageName: String? = null
private var fileDirectory: String? = null private var fileDirectory: String? = null
@@ -36,6 +37,40 @@ object Cocos2dxHelper {
Process.killProcess(Process.myPid()) Process.killProcess(Process.myPid())
} }
@JvmStatic
fun getAssetManager(): AssetManager? {
return sAssetManager
}
@JvmStatic
fun enableAccelerometer() {
sAccelerometerEnabled = true
sCocos2dxAccelerometer?.enable()
}
@JvmStatic
fun setAccelerometerInterval(interval: Float) {
sCocos2dxAccelerometer?.setInterval(interval)
}
@JvmStatic
fun disableAccelerometer() {
sAccelerometerEnabled = false
sCocos2dxAccelerometer?.disable()
}
fun onResume() {
if (sAccelerometerEnabled) {
sCocos2dxAccelerometer?.enable()
}
}
fun onPause() {
if (sAccelerometerEnabled) {
sCocos2dxAccelerometer?.disable()
}
}
@JvmStatic @JvmStatic
fun getDPI(): Int { fun getDPI(): Int {
return BaseRobTopActivity.me.get()?.resources?.configuration?.densityDpi ?: -1 return BaseRobTopActivity.me.get()?.resources?.configuration?.densityDpi ?: -1
@@ -52,14 +87,14 @@ object Cocos2dxHelper {
this.cocos2dxHelperListener = cocos2dxHelperListener this.cocos2dxHelperListener = cocos2dxHelperListener
packageName = applicationInfo.packageName packageName = applicationInfo.packageName
fileDirectory = LaunchUtils.getSaveDirectory(context).absolutePath fileDirectory = LaunchUtils.getSaveDirectory(context).absolutePath
// Cocos2dxHelper.sCocos2dxAccelerometer = Cocos2dxAccelerometer(pContext) sCocos2dxAccelerometer = Cocos2dxAccelerometer(context)
// Cocos2dxHelper.sCocos2dMusic = Cocos2dxMusic(pContext) // Cocos2dxHelper.sCocos2dMusic = Cocos2dxMusic(pContext)
// var simultaneousStreams: Int = Cocos2dxSound.MAX_SIMULTANEOUS_STREAMS_DEFAULT // var simultaneousStreams: Int = Cocos2dxSound.MAX_SIMULTANEOUS_STREAMS_DEFAULT
// if (Cocos2dxHelper.getDeviceModel().indexOf("GT-I9100") !== -1) { // if (Cocos2dxHelper.getDeviceModel().indexOf("GT-I9100") !== -1) {
// simultaneousStreams = Cocos2dxSound.MAX_SIMULTANEOUS_STREAMS_I9100 // simultaneousStreams = Cocos2dxSound.MAX_SIMULTANEOUS_STREAMS_I9100
// } // }
// Cocos2dxHelper.sCocos2dSound = Cocos2dxSound(pContext, simultaneousStreams) // Cocos2dxHelper.sCocos2dSound = Cocos2dxSound(pContext, simultaneousStreams)
// Cocos2dxHelper.sAssetManager = pContext.getAssets() sAssetManager = context.assets
Cocos2dxBitmap.setContext(context) Cocos2dxBitmap.setContext(context)
// Cocos2dxETCLoader.setContext(pContext) // Cocos2dxETCLoader.setContext(pContext)
} }
@@ -5,6 +5,9 @@ import android.os.Build
import javax.microedition.khronos.egl.EGLConfig import javax.microedition.khronos.egl.EGLConfig
import javax.microedition.khronos.opengles.GL10 import javax.microedition.khronos.opengles.GL10
private const val NANOSECONDS_PER_SECOND = 1000000000L
private const val NANOSECONDS_PER_MICROSECOND = 1000000L
@Suppress("unused", "KotlinJniMissingFunction") @Suppress("unused", "KotlinJniMissingFunction")
class Cocos2dxRenderer(private var handler: Cocos2dxGLSurfaceView) : GLSurfaceView.Renderer { class Cocos2dxRenderer(private var handler: Cocos2dxGLSurfaceView) : GLSurfaceView.Renderer {
companion object { companion object {
@@ -62,6 +65,11 @@ class Cocos2dxRenderer(private var handler: Cocos2dxGLSurfaceView) : GLSurfaceVi
private var screenHeight = 0 private var screenHeight = 0
var sendResizeEvents = false var sendResizeEvents = false
var setFrameRate = false var setFrameRate = false
private var mAnimationInterval: Long? = null
fun limitFrameRate(rate: Int) {
mAnimationInterval = NANOSECONDS_PER_SECOND/rate
}
fun setScreenWidthAndHeight(surfaceWidth: Int, surfaceHeight: Int) { fun setScreenWidthAndHeight(surfaceWidth: Int, surfaceHeight: Int) {
screenWidth = surfaceWidth screenWidth = surfaceWidth
@@ -77,10 +85,28 @@ class Cocos2dxRenderer(private var handler: Cocos2dxGLSurfaceView) : GLSurfaceVi
if (setFrameRate && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (setFrameRate && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
handler.updateRefreshRate() handler.updateRefreshRate()
} }
println("renderer surfaceChanged: ${width}x${height}@${handler.display.refreshRate}fps")
} }
override fun onDrawFrame(gl: GL10?) { override fun onDrawFrame(gl: GL10?) {
nativeRender() val animationInterval = mAnimationInterval
if (animationInterval == null) {
nativeRender()
} else {
nativeRender()
val now = System.nanoTime()
val interval = now - this.lastTickInNanoSeconds
if (interval < animationInterval) {
try {
Thread.sleep((animationInterval - interval) / NANOSECONDS_PER_MICROSECOND)
} catch (e: Exception) {}
}
lastTickInNanoSeconds = System.nanoTime()
}
} }
fun handleActionDown(id: Int, x: Float, y: Float) { fun handleActionDown(id: Int, x: Float, y: Float) {
@@ -52,12 +52,10 @@ class Cocos2dxTextInputWrapper(private val cocos2dxGLSurfaceView: Cocos2dxGLSurf
cocos2dxGLSurfaceView.deleteBackward() cocos2dxGLSurfaceView.deleteBackward()
} }
var text = textView.text.toString() var text = textView.text.toString()
if (text.compareTo("") == 0) { if (!text.endsWith('\n')) {
text = "\n" text += '\n'
}
if (10 != text[text.length - 1].code) {
text += 10.toChar()
} }
cocos2dxGLSurfaceView.insertText(text) cocos2dxGLSurfaceView.insertText(text)
} }
if (actionID != 6) { if (actionID != 6) {
@@ -10,8 +10,9 @@ object Cocos2dxTypefaces {
@Synchronized @Synchronized
operator fun get(pContext: Context, pAssetName: String): Typeface? { operator fun get(pContext: Context, pAssetName: String): Typeface? {
var typeface: Typeface? val typeface: Typeface?
var typeface2: Typeface val typeface2: Typeface
synchronized(Cocos2dxTypefaces::class.java) { synchronized(Cocos2dxTypefaces::class.java) {
if (!typefaceCache.containsKey(pAssetName)) { if (!typefaceCache.containsKey(pAssetName)) {
typeface2 = if (pAssetName.startsWith("/")) { typeface2 = if (pAssetName.startsWith("/")) {
@@ -0,0 +1,29 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<group android:scaleX="0.6689189"
android:scaleY="0.6689189"
android:translateX="169.51352"
android:translateY="182.1982">
<path
android:pathData="M553.7,189.9L536.9,177.1C522.2,165.8 501.8,165.8 487.1,177.1L207.6,390.5C194,400.9 188.3,418.8 193.3,435.2L300.9,783.2C306.2,800.3 322.1,812.1 340.1,812.1H683.9C701.9,812.1 717.8,800.3 723.1,783.2L830.7,435.2C835.7,418.8 830,400.9 816.4,390.5L792,371.9L762,466.3L680,733C676.5,744.4 666,752.1 654.2,752.1H368C356.1,752.1 345.7,744.4 342.2,733L252.8,442.3C249.5,431.5 253.2,419.8 262.1,413L494.6,234.5C495.6,233.7 496.6,233.1 497.6,232.5L553.7,189.9Z"
android:fillColor="#000000"
android:fillAlpha="0.25"
android:fillType="evenOdd"/>
<path
android:pathData="M500.6,320.6C508.5,314.5 519.5,314.5 527.4,320.6L686,442.2C693.3,447.8 696.3,457.3 693.6,466.1L632.6,664.4C629.7,673.6 621.2,679.9 611.6,679.9H416.4C406.8,679.9 398.3,673.6 395.4,664.4L385.4,632H439.4C440,632.1 440.7,632.2 441.4,632.2H583.2C588,632.2 592.3,629 593.7,624.4L638,477.5C639.3,473.2 637.8,468.5 634.3,465.7L519.1,375.6C515.1,372.5 509.5,372.5 505.5,375.6L399.1,458.8L344.5,499.1L334.4,466.1C331.7,457.3 334.7,447.8 342,442.2L500.6,320.6Z"
android:fillColor="#000000"
android:fillAlpha="0.25"
android:fillType="evenOdd"/>
<path
android:pathData="M557.1,165.4L536.9,150C522.2,138.8 501.8,138.8 487.1,150L180.9,383.8C167.3,394.2 161.6,412.1 166.7,428.5L284.4,809.4C289.7,826.6 305.6,838.3 323.6,838.3H700.4C718.4,838.3 734.3,826.6 739.6,809.4L857.3,428.5C862.4,412.1 856.7,394.2 843.1,383.8L814.8,362.3L782.4,464.4L693.2,754.4C689.7,765.7 679.2,773.5 667.4,773.5H354.6C342.8,773.5 332.3,765.7 328.8,754.4L231.2,437C227.9,426.2 231.6,414.5 240.6,407.6L494.6,212.6C495.6,211.8 496.6,211.1 497.7,210.5L557.1,165.4Z"
android:fillType="evenOdd"
android:fillColor="#ffffff" />
<path
android:pathData="M500.6,284.3C508.5,278.2 519.5,278.2 527.4,284.3L716.8,429.5C724.1,435.1 727.1,444.6 724.4,453.4L651.6,689.9C648.8,699.2 640.3,705.5 630.6,705.5H397.4C387.7,705.5 379.2,699.2 376.4,689.9L363.9,649.5H425.7C426.4,649.6 427.1,649.7 427.9,649.7H596.1C601,649.7 605.2,646.5 606.6,641.9L659.1,468C660.5,463.6 659,458.9 655.4,456.1L518.8,349.3C514.8,346.2 509.2,346.2 505.2,349.3L379.9,447.3L316.2,494.3L303.6,453.4C300.9,444.6 303.9,435.1 311.2,429.5L500.6,284.3Z"
android:fillType="evenOdd"
android:fillColor="#ffffff" />
</group>
</vector>
@@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="96dp"
android:height="96dp"
android:viewportWidth="696"
android:viewportHeight="698"
android:tint="?attr/colorControlNormal">
<path
android:pathData="M393.08,24.42L372.88,9C358.19,-2.22 337.81,-2.22 323.12,9L16.94,242.81C3.28,253.24 -2.42,271.09 2.66,287.51L120.41,668.43C125.72,685.61 141.6,697.33 159.58,697.33H536.42C554.4,697.33 570.28,685.61 575.59,668.43L693.34,287.51C698.42,271.09 692.72,253.24 679.06,242.81L650.84,221.26L618.37,323.39L529.2,613.39C525.71,624.72 515.24,632.46 503.39,632.46H190.61C178.76,632.46 168.29,624.72 164.8,613.39L67.2,295.97C63.88,285.19 67.62,273.49 76.56,266.62L330.56,71.62C331.58,70.84 332.64,70.14 333.74,69.52L393.08,24.42Z"
android:fillType="evenOdd"
android:fillColor="@android:color/white">
</path>
<path
android:pathData="M336.61,143.26C344.51,137.21 355.49,137.21 363.39,143.26L552.8,288.49C560.1,294.09 563.14,303.63 560.44,312.42L487.62,548.92C484.78,558.15 476.25,564.45 466.59,564.45H233.41C223.75,564.45 215.22,558.15 212.38,548.92L199.93,508.5H261.68C262.39,508.65 263.13,508.72 263.89,508.72H432.11C436.96,508.72 441.24,505.55 442.64,500.9L495.14,326.98C496.45,322.64 494.96,317.93 491.39,315.14L354.78,208.3C350.8,205.19 345.2,205.19 341.22,208.3L215.93,306.29L152.15,353.31L139.56,312.42C136.86,303.63 139.9,294.09 147.2,288.49L336.61,143.26Z"
android:fillType="evenOdd"
android:fillColor="@android:color/white">
</path>
</vector>
@@ -0,0 +1,28 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<group android:scaleX="0.75" android:scaleY="0.75" android:translateX="128" android:translateY="128">
<clip-path
android:pathData="M0,0h1024v1024h-1024z"/>
<path
android:pathData="M0,0h1024v1024h-1024z"
android:fillColor="#F4D48E"/>
<path
android:pathData="M512,512m-512,0a512,512 0,1 1,1024 0a512,512 0,1 1,-1024 0"
android:fillColor="#F4D48E"/>
<path
android:pathData="M719.5,289C618,241.7 526,152.5 320.5,152.5C115,152.5 3.5,248 -8.5,293C-20.5,338 -8.2,391.5 -13.5,390.5L-33,706.5H1145L1137.5,460C1115.5,426.8 1043.5,399 1007,391.5C970.5,384 822.5,337 719.5,289Z"
android:fillColor="#F4A47B"/>
<path
android:pathData="M561.5,514.5C488.5,476.5 341,402 214.5,402C88,402 30.3,440.7 -8.5,461.5C-47.3,482.3 -54,485.2 -59,486.5L-68.5,808L563.5,814.5L561.5,514.5Z"
android:fillColor="#E27880"/>
<path
android:pathData="M1020.5,551C994,521.2 893.7,454.1 662.5,452.5C539.2,451.6 363,505.6 259,577.5C163.9,643.2 43,680 -13,686L-124,934.5H1187.5L1020.5,551Z"
android:fillColor="#B9588F"/>
<path
android:pathData="M819.5,856C638,856 518,772.5 359,772.5C200,772.5 100,860.5 65,869L-94,949.5V1041.5L1177.5,1025.5L1032.5,738C1028,758 967.4,856 819.5,856Z"
android:fillColor="#714A9A"/>
</group>
</vector>
@@ -0,0 +1,28 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<group android:scaleX="0.75" android:scaleY="0.75" android:translateX="128" android:translateY="128">
<clip-path
android:pathData="M0,0h1024v1024h-1024z"/>
<path
android:pathData="M0,0h1024v1024h-1024z"
android:fillColor="#CC7C7C"/>
<path
android:pathData="M512,512m-512,0a512,512 0,1 1,1024 0a512,512 0,1 1,-1024 0"
android:fillColor="#CC7C7C"/>
<path
android:pathData="M719.5,289C618,241.7 526,152.5 320.5,152.5C115,152.5 3.5,248 -8.5,293C-20.5,338 -8.2,391.5 -13.5,390.5L-33,706.5H1145L1137.5,460C1115.5,426.8 1043.5,399 1007,391.5C970.5,384 822.5,337 719.5,289Z"
android:fillColor="#C6B471"/>
<path
android:pathData="M561.5,514.5C488.5,476.5 341,402 214.5,402C88,402 30.3,440.7 -8.5,461.5C-47.3,482.3 -54,485.2 -59,486.5L-68.5,808L563.5,814.5L561.5,514.5Z"
android:fillColor="#59AF7F"/>
<path
android:pathData="M1020.5,551C994,521.2 893.7,454.1 662.5,452.5C539.2,451.6 363,505.6 259,577.5C163.9,643.2 43,680 -13,686L-124,934.5H1187.5L1020.5,551Z"
android:fillColor="#5F8CAB"/>
<path
android:pathData="M819.5,856C638,856 518,772.5 359,772.5C200,772.5 100,860.5 65,869L-94,949.5V1041.5L1177.5,1025.5L1032.5,738C1028,758 967.4,856 819.5,856Z"
android:fillColor="#70558D"/>
</group>
</vector>
@@ -0,0 +1,28 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<group android:scaleX="0.75" android:scaleY="0.75" android:translateX="128" android:translateY="128">
<clip-path
android:pathData="M0,0h1024v1024h-1024z"/>
<path
android:pathData="M0,0h1024v1024h-1024z"
android:fillColor="#65B8C2"/>
<path
android:pathData="M512,512m-512,0a512,512 0,1 1,1024 0a512,512 0,1 1,-1024 0"
android:fillColor="#65B8C2"/>
<path
android:pathData="M719.5,289C618,241.7 526,152.5 320.5,152.5C115,152.5 3.5,248 -8.5,293C-20.5,338 -8.2,391.5 -13.5,390.5L-33,706.5H1145L1137.5,460C1115.5,426.8 1043.5,399 1007,391.5C970.5,384 822.5,337 719.5,289Z"
android:fillColor="#DEA5CD"/>
<path
android:pathData="M561.5,514.5C488.5,476.5 341,402 214.5,402C88,402 30.3,440.7 -8.5,461.5C-47.3,482.3 -54,485.2 -59,486.5L-68.5,808L563.5,814.5L561.5,514.5Z"
android:fillColor="#DEE8EA"/>
<path
android:pathData="M1020.5,551C994,521.2 893.7,454.1 662.5,452.5C539.2,451.6 363,505.6 259,577.5C163.9,643.2 43,680 -13,686L-124,934.5H1187.5L1020.5,551Z"
android:fillColor="#C485BD"/>
<path
android:pathData="M819.5,856C638,856 518,772.5 359,772.5C200,772.5 100,860.5 65,869L-94,949.5V1041.5L1177.5,1025.5L1032.5,738C1028,758 967.4,856 819.5,856Z"
android:fillColor="#259CB4"/>
</group>
</vector>
+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="M440,520L200,520L200,440L440,440L440,200L520,200L520,440L760,440L760,520L520,520L520,760L440,760L440,520Z"/>
</vector>
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M313,520L537,744L480,800L160,480L480,160L537,216L313,440L800,440L800,520L313,520Z"/>
</vector>
+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="M382,720L154,492L211,435L382,606L749,239L806,296L382,720Z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M336,680L280,624L424,480L280,337L336,281L480,425L623,281L679,337L535,480L679,624L623,680L480,536L336,680Z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M560,800L560,720L680,720Q697,720 708.5,708.5Q720,697 720,680L720,600Q720,562 742,531Q764,500 800,487L800,473Q764,460 742,429Q720,398 720,360L720,280Q720,263 708.5,251.5Q697,240 680,240L560,240L560,160L680,160Q730,160 765,195Q800,230 800,280L800,360Q800,377 811.5,388.5Q823,400 840,400L880,400L880,560L840,560Q823,560 811.5,571.5Q800,583 800,600L800,680Q800,730 765,765Q730,800 680,800L560,800ZM280,800Q230,800 195,765Q160,730 160,680L160,600Q160,583 148.5,571.5Q137,560 120,560L80,560L80,400L120,400Q137,400 148.5,388.5Q160,377 160,360L160,280Q160,230 195,195Q230,160 280,160L400,160L400,240L280,240Q263,240 251.5,251.5Q240,263 240,280L240,360Q240,398 218,429Q196,460 160,473L160,487Q196,500 218,531Q240,562 240,600L240,680Q240,697 251.5,708.5Q263,720 280,720L400,720L400,800L280,800Z"/>
</vector>
+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="M160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L400,160L480,240L800,240Q833,240 856.5,263.5Q880,287 880,320L880,720Q880,753 856.5,776.5Q833,800 800,800L160,800ZM160,720L800,720Q800,720 800,720Q800,720 800,720L800,320Q800,320 800,320Q800,320 800,320L447,320L367,240L160,240Q160,240 160,240Q160,240 160,240L160,720Q160,720 160,720Q160,720 160,720ZM160,720Q160,720 160,720Q160,720 160,720L160,240Q160,240 160,240Q160,240 160,240L160,240L160,320L160,320Q160,320 160,320Q160,320 160,320L160,720Q160,720 160,720Q160,720 160,720Z"/>
</vector>
+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="M440,680L520,680L520,440L440,440L440,680ZM480,360Q497,360 508.5,348.5Q520,337 520,320Q520,303 508.5,291.5Q497,280 480,280Q463,280 451.5,291.5Q440,303 440,320Q440,337 451.5,348.5Q463,360 480,360ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z"/>
</vector>
+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="M440,680L280,680Q197,680 138.5,621.5Q80,563 80,480Q80,397 138.5,338.5Q197,280 280,280L440,280L440,360L280,360Q230,360 195,395Q160,430 160,480Q160,530 195,565Q230,600 280,600L440,600L440,680ZM320,520L320,440L640,440L640,520L320,520ZM520,680L520,600L680,600Q730,600 765,565Q800,530 800,480Q800,430 765,395Q730,360 680,360L520,360L520,280L680,280Q763,280 821.5,338.5Q880,397 880,480Q880,563 821.5,621.5Q763,680 680,680L520,680Z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M480,800Q447,800 423.5,776.5Q400,753 400,720Q400,687 423.5,663.5Q447,640 480,640Q513,640 536.5,663.5Q560,687 560,720Q560,753 536.5,776.5Q513,800 480,800ZM480,560Q447,560 423.5,536.5Q400,513 400,480Q400,447 423.5,423.5Q447,400 480,400Q513,400 536.5,423.5Q560,447 560,480Q560,513 536.5,536.5Q513,560 480,560ZM480,320Q447,320 423.5,296.5Q400,273 400,240Q400,207 423.5,183.5Q447,160 480,160Q513,160 536.5,183.5Q560,207 560,240Q560,273 536.5,296.5Q513,320 480,320Z"/>
</vector>
+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="M480,480Q414,480 367,433Q320,386 320,320Q320,254 367,207Q414,160 480,160Q546,160 593,207Q640,254 640,320Q640,386 593,433Q546,480 480,480ZM160,800L160,688Q160,654 177.5,625.5Q195,597 224,582Q286,551 350,535.5Q414,520 480,520Q546,520 610,535.5Q674,551 736,582Q765,597 782.5,625.5Q800,654 800,688L800,800L160,800ZM240,720L720,720L720,688Q720,677 714.5,668Q709,659 700,654Q646,627 591,613.5Q536,600 480,600Q424,600 369,613.5Q314,627 260,654Q251,659 245.5,668Q240,677 240,688L240,720ZM480,400Q513,400 536.5,376.5Q560,353 560,320Q560,287 536.5,263.5Q513,240 480,240Q447,240 423.5,263.5Q400,287 400,320Q400,353 423.5,376.5Q447,400 480,400ZM480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320ZM480,720L480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720L480,720Z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M720,560L720,440L600,440L600,360L720,360L720,240L800,240L800,360L920,360L920,440L800,440L800,560L720,560ZM360,480Q294,480 247,433Q200,386 200,320Q200,254 247,207Q294,160 360,160Q426,160 473,207Q520,254 520,320Q520,386 473,433Q426,480 360,480ZM40,800L40,688Q40,654 57.5,625.5Q75,597 104,582Q166,551 230,535.5Q294,520 360,520Q426,520 490,535.5Q554,551 616,582Q645,597 662.5,625.5Q680,654 680,688L680,800L40,800ZM120,720L600,720L600,688Q600,677 594.5,668Q589,659 580,654Q526,627 471,613.5Q416,600 360,600Q304,600 249,613.5Q194,627 140,654Q131,659 125.5,668Q120,677 120,688L120,720ZM360,400Q393,400 416.5,376.5Q440,353 440,320Q440,287 416.5,263.5Q393,240 360,240Q327,240 303.5,263.5Q280,287 280,320Q280,353 303.5,376.5Q327,400 360,400ZM360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320ZM360,720L360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720L360,720Z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M320,760L320,200L760,480L320,760ZM400,480L400,480L400,480ZM400,614L610,480L400,346L400,614Z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M424,640Q424,559 438.5,523.5Q453,488 500,446Q541,410 562.5,383.5Q584,357 584,323Q584,282 556.5,255Q529,228 480,228Q429,228 402.5,259Q376,290 365,322L262,278Q283,214 339,167Q395,120 480,120Q585,120 641.5,178.5Q698,237 698,319Q698,369 676.5,404.5Q655,440 609,485Q560,532 549.5,556.5Q539,581 539,640L424,640ZM480,880Q447,880 423.5,856.5Q400,833 400,800Q400,767 423.5,743.5Q447,720 480,720Q513,720 536.5,743.5Q560,767 560,800Q560,833 536.5,856.5Q513,880 480,880Z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M480,800Q346,800 253,707Q160,614 160,480Q160,346 253,253Q346,160 480,160Q549,160 612,188.5Q675,217 720,270L720,160L800,160L800,440L520,440L520,360L688,360Q656,304 600.5,272Q545,240 480,240Q380,240 310,310Q240,380 240,480Q240,580 310,650Q380,720 480,720Q557,720 619,676Q681,632 706,560L790,560Q762,666 676,733Q590,800 480,800Z"/>
</vector>
+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="M200,520L200,440L760,440L760,520L200,520Z"/>
</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,720L240,240L320,240L320,720L240,720ZM400,720L800,480L400,240L400,720ZM480,579L480,381L645,480L480,579ZM480,480L480,480L480,480Z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M370,880L354,752Q341,747 329.5,740Q318,733 307,725L188,775L78,585L181,507Q180,500 180,493.5Q180,487 180,480Q180,473 180,466.5Q180,460 181,453L78,375L188,185L307,235Q318,227 330,220Q342,213 354,208L370,80L590,80L606,208Q619,213 630.5,220Q642,227 653,235L772,185L882,375L779,453Q780,460 780,466.5Q780,473 780,480Q780,487 780,493.5Q780,500 778,507L881,585L771,775L653,725Q642,733 630,740Q618,747 606,752L590,880L370,880ZM440,800L519,800L533,694Q564,686 590.5,670.5Q617,655 639,633L738,674L777,606L691,541Q696,527 698,511.5Q700,496 700,480Q700,464 698,448.5Q696,433 691,419L777,354L738,286L639,328Q617,305 590.5,289.5Q564,274 533,266L520,160L441,160L427,266Q396,274 369.5,289.5Q343,305 321,327L222,286L183,354L269,418Q264,433 262,448Q260,463 260,480Q260,496 262,511Q264,526 269,541L183,606L222,674L321,632Q343,655 369.5,670.5Q396,686 427,694L440,800ZM482,620Q540,620 581,579Q622,538 622,480Q622,422 581,381Q540,340 482,340Q423,340 382.5,381Q342,422 342,480Q342,538 382.5,579Q423,620 482,620ZM480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480Z"/>
</vector>
+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="M680,880Q630,880 595,845Q560,810 560,760Q560,754 563,732L282,568Q266,583 245,591.5Q224,600 200,600Q150,600 115,565Q80,530 80,480Q80,430 115,395Q150,360 200,360Q224,360 245,368.5Q266,377 282,392L563,228Q561,221 560.5,214.5Q560,208 560,200Q560,150 595,115Q630,80 680,80Q730,80 765,115Q800,150 800,200Q800,250 765,285Q730,320 680,320Q656,320 635,311.5Q614,303 598,288L317,452Q319,459 319.5,465.5Q320,472 320,480Q320,488 319.5,494.5Q319,501 317,508L598,672Q614,657 635,648.5Q656,640 680,640Q730,640 765,675Q800,710 800,760Q800,810 765,845Q730,880 680,880ZM680,800Q697,800 708.5,788.5Q720,777 720,760Q720,743 708.5,731.5Q697,720 680,720Q663,720 651.5,731.5Q640,743 640,760Q640,777 651.5,788.5Q663,800 680,800ZM200,520Q217,520 228.5,508.5Q240,497 240,480Q240,463 228.5,451.5Q217,440 200,440Q183,440 171.5,451.5Q160,463 160,480Q160,497 171.5,508.5Q183,520 200,520ZM680,240Q697,240 708.5,228.5Q720,217 720,200Q720,183 708.5,171.5Q697,160 680,160Q663,160 651.5,171.5Q640,183 640,200Q640,217 651.5,228.5Q663,240 680,240ZM680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760ZM200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480ZM680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Z"/>
</vector>
+11
View File
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M280,760L280,680L564,680Q627,680 673.5,640Q720,600 720,540Q720,480 673.5,440Q627,400 564,400L312,400L416,504L360,560L160,360L360,160L416,216L312,320L564,320Q661,320 730.5,383Q800,446 800,540Q800,634 730.5,697Q661,760 564,760L280,760Z"/>
</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="M40,840L480,80L920,840L40,840ZM178,760L782,760L480,240L178,760ZM480,720Q497,720 508.5,708.5Q520,697 520,680Q520,663 508.5,651.5Q497,640 480,640Q463,640 451.5,651.5Q440,663 440,680Q440,697 451.5,708.5Q463,720 480,720ZM440,600L520,600L520,400L440,400L440,600ZM480,500L480,500L480,500Z"/>
</vector>
Binary file not shown.
Binary file not shown.
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_geode_background"/>
<foreground android:drawable="@drawable/ic_launcher_monochrome_shadow"/>
</adaptive-icon>
@@ -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_pride_background"/>
<foreground android:drawable="@drawable/ic_launcher_monochrome_shadow"/>
</adaptive-icon>
@@ -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_trans_background"/>
<foreground android:drawable="@drawable/ic_launcher_monochrome_shadow"/>
</adaptive-icon>
+153 -15
View File
@@ -3,15 +3,16 @@
<string name="no_activity_found">No activity found for this action.</string> <string name="no_activity_found">No activity found for this action.</string>
<!-- MainActivity strings --> <!-- MainActivity strings -->
<string name="game_not_found">Geometry Dash was not found.</string> <string name="game_not_found">Geometry Dash was not found.\nThe full version of the game is required.</string>
<string name="launcher_launch">Launch</string> <string name="launcher_launch">Launch</string>
<string name="launcher_settings">Settings</string> <string name="launcher_settings">Settings</string>
<string name="launcher_download">Download</string> <string name="launcher_download">Download</string>
<string name="launcher_install">Install</string>
<string name="launcher_title">Geode</string> <string name="launcher_title">Geode</string>
<string name="launcher_logo_alt">geode logo</string> <string name="launcher_logo_alt">geode logo</string>
<string name="launcher_launch_icon_alt">play icon</string> <string name="launcher_launch_icon_alt">play icon</string>
<string name="launcher_download_icon_alt">download icon</string> <string name="launcher_download_icon_alt">download icon</string>
<string name="launcher_download_game">Download Game</string> <string name="launcher_download_game">Download game</string>
<string name="launcher_download_32bit">Download the 32-bit version of launcher.</string> <string name="launcher_download_32bit">Download the 32-bit version of launcher.</string>
<string name="launcher_download_universal">Download the non 32-bit version of launcher.</string> <string name="launcher_download_universal">Download the non 32-bit version of launcher.</string>
<string name="launcher_settings_icon_alt">settings icon</string> <string name="launcher_settings_icon_alt">settings icon</string>
@@ -32,8 +33,17 @@
<string name="launcher_unsupported_version_description"> <string name="launcher_unsupported_version_description">
The current version of Geometry Dash is not compatible with the version supported by the launcher. A launcher update is required to fix this.\nLaunch will continue, but crashes may occur! The current version of Geometry Dash is not compatible with the version supported by the launcher. A launcher update is required to fix this.\nLaunch will continue, but crashes may occur!
</string> </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_update_available">A new launcher update is available!</string> <string name="launcher_update_available">A new launcher update is available!</string>
<string name="launcher_update_dismiss">Dismiss</string> <string name="launcher_update_dismiss">Dismiss</string>
<string name="launcher_update_view">View</string>
<string name="launcher_update_external_link">View in browser</string>
<string name="launcher_update_name">Geode Launcher v%1$s</string>
<string name="launcher_update_changelog">Changelog</string>
<string name="launcher_update_released">Released %1$s</string>
<string name="safe_mode_enable_title">Enable safe mode?</string> <string name="safe_mode_enable_title">Enable safe mode?</string>
<string name="safe_mode_enable_description">Safe mode disables loading of all mods, allowing one to remove problematic mods or diagnose crashes. If you\'re not experiencing issues with Geode, you should probably not be using safe mode.</string> <string name="safe_mode_enable_description">Safe mode disables loading of all mods, allowing one to remove problematic mods or diagnose crashes. If you\'re not experiencing issues with Geode, you should probably not be using safe mode.</string>
<string name="launcher_failed_to_load">Geode failed to load.</string> <string name="launcher_failed_to_load">Geode failed to load.</string>
@@ -45,7 +55,28 @@
<string name="launcher_error_copy">Copy details</string> <string name="launcher_error_copy">Copy details</string>
<string name="launcher_error_export_crash">Share crash</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_export_missing">No crash dump was found!</string>
<string name="launcher_error_device_info">Android %2$s, %1$s</string> <string name="launcher_error_device_info">Android %2$s, %1$s (%5$s)\nGD %4$s, Geode %3$s + v%6$s</string>
<string name="launcher_notification_settings">Launcher Settings</string>
<string name="launcher_notification_update_failed">Failed to update Geode.</string>
<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_update_cta">Tap for more information.</string>
<string name="launcher_starting_game">Starting Geometry Dash…</string>
<string name="launcher_downloading_update">Downloading update…</string>
<string name="launcher_downloading_update_details">(%1$s/%2$s)</string>
<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_error">Could not start Geode.</string>
<string name="launcher_cancelled_outdated">A Geometry Dash update is required.</string>
<string name="launcher_cancelled_restart">Restart</string>
<string name="launcher_cancelled_resume">Resume</string>
<string name="launcher_cancelled_retry">Retry</string>
<string name="launcher_cancelled_help_alt">Help</string>
<string name="launcher_in_safe_mode">Starting in safe mode…</string>
<!-- Updater strings --> <!-- Updater strings -->
<string name="release_fetch_failed">Update failed.\n%1$s</string> <string name="release_fetch_failed">Update failed.\n%1$s</string>
@@ -53,6 +84,7 @@
<string name="release_fetch_in_progress">Checking for new releases…</string> <string name="release_fetch_in_progress">Checking for new releases…</string>
<string name="release_fetch_success">Geode updated!</string> <string name="release_fetch_success">Geode updated!</string>
<string name="release_fetch_downloading">Downloading update… (%1$s/%2$s)</string> <string name="release_fetch_downloading">Downloading update… (%1$s/%2$s)</string>
<string name="release_fetch_downloading_indeterminate">Downloading update… (%1$s)</string>
<string name="release_fetch_cancelling">Cancelling update…</string> <string name="release_fetch_cancelling">Cancelling update…</string>
<string name="release_fetch_no_internet">No internet found.</string> <string name="release_fetch_no_internet">No internet found.</string>
<string name="release_fetch_cancelled">Update cancelled.</string> <string name="release_fetch_cancelled">Update cancelled.</string>
@@ -75,6 +107,7 @@
<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_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_report">Report this issue to the developers.</string>
<string name="load_failed_recommendation_safe_mode">Hold the Launch button to launch in safe mode.</string> <string name="load_failed_recommendation_safe_mode">Hold the Launch button to launch in safe mode.</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> <string name="message_box_cancel">Cancel</string>
<string name="message_box_accept">OK</string> <string name="message_box_accept">OK</string>
@@ -87,7 +120,7 @@
<string name="title_activity_settings">Settings</string> <string name="title_activity_settings">Settings</string>
<string name="back_icon_alt">back</string> <string name="back_icon_alt">back</string>
<string name="export_folder_tag">Launcher External Folder</string> <string name="export_folder_tag">Launcher external folder</string>
<string name="text_copied">Copied to clipboard.</string> <string name="text_copied">Copied to clipboard.</string>
<string name="preference_category_testing">General</string> <string name="preference_category_testing">General</string>
@@ -96,6 +129,7 @@
<string name="preferences_copy_external_button">Copy external files directory</string> <string name="preferences_copy_external_button">Copy external files directory</string>
<string name="preferences_open_file_manager">Open file manager</string> <string name="preferences_open_file_manager">Open file manager</string>
<string name="preferences_view_logs">View application logs</string> <string name="preferences_view_logs">View application logs</string>
<string name="preferences_view_crashes">View crash dumps</string>
<string name="preference_theme_name">Theme</string> <string name="preference_theme_name">Theme</string>
<string name="preference_theme_select">Select theme</string> <string name="preference_theme_select">Select theme</string>
@@ -103,21 +137,78 @@
<string name="preference_theme_light">Light</string> <string name="preference_theme_light">Light</string>
<string name="preference_theme_dark">Dark</string> <string name="preference_theme_dark">Dark</string>
<string name="preference_black_background_name">Black background in dark theme</string> <string name="preference_black_background_name">Black background in dark theme</string>
<string name="preference_disable_styles">Disable custom styling</string>
<string name="preference_set_application_icon">Application icon</string>
<string name="preference_force_hrr">Force maximum refresh rate</string> <string name="preference_force_hrr">Force maximum refresh rate</string>
<string name="preference_enable_redesign">✨ Enable UI refresh</string>
<string name="preference_display_mode_name">Screen scale</string> <string name="preference_display_mode_name">Display mode</string>
<string name="preference_display_mode_select">Select screen scale</string> <string name="preference_display_mode_select">Select display mode</string>
<string name="preference_display_mode_legacy">Limited (16:9)</string> <string name="preference_display_mode_legacy">Limited (Custom)</string>
<string name="preference_display_mode_fullscreen">Edge-to-edge</string> <string name="preference_display_mode_fullscreen">Edge-to-edge</string>
<string name="preference_display_mode_default">Default</string> <string name="preference_display_mode_default">Default</string>
<string name="preference_custom_aspect_ratio_name">Aspect ratio</string>
<string name="preference_custom_aspect_ratio_description">Limits aspect ratio of the game.</string>
<string name="preference_custom_aspect_ratio_select">Select aspect ratio</string>
<string name="preference_custom_aspect_ratio_16x9">16:9</string>
<string name="preference_custom_aspect_ratio_16x10">16:10</string>
<string name="preference_custom_aspect_ratio_4x3">4:3</string>
<string name="preference_custom_aspect_ratio_1x1">1:1</string>
<string name="preference_screen_zoom_name">Screen zoom</string>
<string name="preference_screen_zoom_description">Reduces the size of the game window.</string>
<string name="preference_screen_zoom_select">Edit screen zoom</string>
<string name="preference_screen_zoom_fit_name">Fit to screen</string>
<string name="preference_limit_framerate_name">Limit max framerate</string>
<string name="preference_limit_framerate_description">Limits framerate during rendering.</string>
<string name="preference_limit_framerate_select">Edit maximum framerate</string>
<string name="preference_limit_framerate_default">Disabled</string>
<string name="preference_limit_framerate_label">Framerate</string>
<string name="preference_limit_framerate_suffix">FPS</string>
<string name="preference_limit_framerate_value">%1$d FPS</string>
<string name="preference_limit_framerate_error_max">This device supports at most %1$s FPS.</string>
<string name="preference_limit_framerate_error_min">Framerate must be at least %1$s FPS.</string>
<string name="preference_limit_framerate_subtract">Decrement</string>
<string name="preference_limit_framerate_add">Increment</string>
<string name="preference_limit_framerate_reset">Disable</string>
<string name="preference_category_updater">Updates</string> <string name="preference_category_updater">Updates</string>
<string name="preference_update_automatically_name">Enable automatic updates</string> <string name="preference_update_automatically_name">Enable automatic updates</string>
<string name="preference_release_channel_name">Use nightly release channel</string> <string name="preference_release_channel_name">Use nightly release channel</string>
<string name="preference_category_gameplay">Gameplay</string>
<string name="preference_release_channel_tag_name">Release channel</string>
<string name="preference_release_channel_select">Select release channel</string>
<string name="preference_release_channel_stable">Stable</string>
<string name="preference_release_channel_beta">Pre-release</string>
<string name="preference_release_channel_nightly">Nightly</string>
<string name="preference_category_about">About</string>
<string name="preference_launcher_version_name">Android Launcher</string>
<string name="preference_launcher_version_description">v%1$s</string>
<string name="preference_launcher_version">Android Launcher v%1$s</string>
<string name="preference_loader_version_name">Geode</string>
<string name="preference_loader_version_description">%1$s</string>
<string name="preference_loader_version">Geode %1$s</string>
<string name="preference_game_version_name">Geometry Dash</string>
<string name="preference_game_version_description">%1$s (%2$s)</string>
<string name="preference_game_version">Geometry Dash %1$s (%2$s)</string>
<string name="preference_game_source_google">Google</string>
<string name="preference_game_source_amazon">Amazon</string>
<string name="preference_game_source_unknown">Unknown</string>
<string name="preference_architecture_name">Device</string>
<string name="preference_architecture_description">%1$s (%2$s)</string>
<string name="preference_architecture">%1$s (%2$s)</string>
<string name="preference_check_for_updates_button">Check for updates</string> <string name="preference_check_for_updates_button">Check for updates</string>
<string name="preference_check_for_updates_description">Current loader version: %1$s</string> <string name="preference_check_for_updates_description">Current loader version: %1$s</string>
<string name="preference_launcher_version">Geode Launcher v%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. See logs for more details.</string>
<string name="preference_check_for_updates_none_found">No updates found.</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_success">Geode has been updated!</string>
@@ -125,23 +216,70 @@
<string name="preference_check_for_updates_no_internet">No internet found.</string> <string name="preference_check_for_updates_no_internet">No internet found.</string>
<string name="preference_check_for_updates_already_updating">An update is already in progress!</string> <string name="preference_check_for_updates_already_updating">An update is already in progress!</string>
<string name="preference_category_developer">Developer</string> <string name="preference_category_developer">Advanced</string>
<string name="preference_launch_arguments_name">Launch arguments</string> <string name="preference_launch_arguments_name">Launch arguments</string>
<string name="preference_launch_arguments_set_title">Set launch arguments</string> <string name="preference_launch_arguments_set_title">Set launch arguments</string>
<string name="preference_text_clear">Clear</string> <string name="preference_text_clear">Clear</string>
<string name="preference_open_developer_options">Open developer options</string>
<string name="preference_developer_options_dialog_title">Enable developer options?</string>
<string name="preference_profiles">Profiles</string>
<string name="preference_profiles_create">Create profile</string>
<string name="preference_profiles_name">Change profile</string>
<string name="preference_profiles_select">Select profile</string>
<string name="preference_profiles_create_name">Profile name</string>
<string name="preference_profiles_create_action">Create</string>
<string name="preference_profiles_select_value">%1$s</string>
<string name="preference_profiles_current">Current profile: %1$s</string>
<string name="preference_profiles_create_info">This profile will be created at %1$s.</string>
<string name="preference_profiles_create_duplicate">A profile with this name already exists.</string>
<string name="preference_profiles_delete_alt">Delete</string>
<string name="preference_profiles_delete_undo_alt">Undo</string>
<!-- ApplicationLogsActivity strings --> <!-- ApplicationLogsActivity strings -->
<string name="title_activity_application_logs">Application Logs</string> <string name="title_activity_application_logs">Application logs</string>
<string name="application_logs_no_logs">No logs available.</string> <string name="application_logs_no_logs">No logs available.</string>
<string name="application_logs_delete">Clear All</string> <string name="application_logs_delete">Clear all</string>
<string name="application_logs_share">Share</string> <string name="application_logs_share">Share</string>
<string name="application_log_copy_label">Log Message</string> <string name="application_log_copy_label">Log message</string>
<string name="application_logs_more">More</string> <string name="application_logs_more">More</string>
<string name="application_logs_export">Save To</string> <string name="application_logs_export">Save to</string>
<string name="application_logs_level">Set Log Level</string> <string name="application_logs_level">Set log level</string>
<string name="application_logs_level_dialog_name">Set minimum log level</string> <string name="application_logs_level_dialog_name">Set minimum log level</string>
<string name="application_logs_export_completed">Log export complete.</string> <string name="application_logs_export_completed">Log export complete.</string>
<string name="application_logs_default_filename" translatable="false">launcher_logcat.txt</string> <string name="application_logs_default_filename" translatable="false">launcher_logcat.txt</string>
<string name="application_logs_crash_only">Show Crash Buffer</string> <string name="application_logs_crash_only">Show crash buffer</string>
<string name="application_logs_crash_only_enabled_alt">Enabled</string> <string name="application_logs_crash_only_enabled_alt">Enabled</string>
<!-- DeveloperSettingsActivity strings -->
<string name="title_activity_developer_settings">Developer options</string>
<string name="preference_developer_mode">Enable developer options</string>
<string name="preference_developer_mode_about">Developer mode allows changing options that may make the game unstable. Please do not modify them unless you know what you\'re doing!\nThese options may change behavior, or be removed entirely in future updates.</string>
<string name="preference_developer_update_notice">Enabling developer mode automatically disallows the updater from overwriting custom loader builds.</string>
<string name="preference_override_exceptions_name">Custom exceptions fix symbols</string>
<string name="preference_override_exceptions_description">Uses exception_symbols.txt to patch</string>
<string name="preference_use_index_update_api">Use index release API</string>
<string name="preference_development_server_name">Enable development server</string>
<string name="preference_development_server_description">Opens a public development server on port 9043.</string>
<string name="notification_channel_development_server">Development Server Service</string>
<string name="notification_channel_development_server_description">Indicates the development server is currently active.</string>
<!-- CrashLogsActivity strings -->
<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>
<!-- ApplicationIconActivity strings -->
<string name="title_activity_application_icon">Application icons</string>
<string name="application_icon_set_failed">Failed to change icon.</string>
<string name="application_icon_default">Default</string>
<string name="application_icon_geode">Geode</string>
<string name="application_icon_pride">Pride</string>
<string name="application_icon_trans">Transgender</string>
</resources> </resources>
+2 -1
View File
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.NoActionBar.Fullscreen" parent="@style/Theme.AppCompat.DayNight.NoActionBar"> <style name="Theme.NoActionBar.Fullscreen" parent="@style/Theme.AppCompat.DayNight.NoActionBar">
<item name="android:windowNoTitle">true</item> <item name="android:windowNoTitle">true</item>
@@ -7,6 +7,7 @@
<item name="android:windowFullscreen">true</item> <item name="android:windowFullscreen">true</item>
<item name="android:windowContentOverlay">@null</item> <item name="android:windowContentOverlay">@null</item>
<item name="android:windowBackground">@android:drawable/screen_background_dark</item> <item name="android:windowBackground">@android:drawable/screen_background_dark</item>
<item name="android:windowOptOutEdgeToEdgeEnforcement" tools:targetApi="35">true</item>
</style> </style>
<style name="Theme.GeodeLauncher" parent="android:Theme.Material.Light.NoActionBar" /> <style name="Theme.GeodeLauncher" parent="android:Theme.Material.Light.NoActionBar" />
+6 -6
View File
@@ -1,14 +1,14 @@
buildscript { buildscript {
val composeBOM by extra("2024.05.00") val composeBOM by extra("2025.12.00")
} }
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins { plugins {
id("com.android.application") version "8.4.1" apply false id("com.android.application") version "8.13.2" apply false
id("com.android.library") version "8.4.1" apply false id("com.android.library") version "8.13.2" apply false
id("org.jetbrains.kotlin.android") version "2.0.0" apply false id("org.jetbrains.kotlin.android") version "2.3.0" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.0" apply false id("org.jetbrains.kotlin.plugin.serialization") version "2.3.0" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.0" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.3.0" apply false
} }
tasks.register<Delete>("clean") { tasks.register<Delete>("clean") {
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
Vendored
+5 -3
View File
@@ -15,6 +15,8 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
# #
# SPDX-License-Identifier: Apache-2.0
#
############################################################################## ##############################################################################
# #
@@ -55,7 +57,7 @@
# Darwin, MinGW, and NonStop. # Darwin, MinGW, and NonStop.
# #
# (3) This script is generated from the Groovy template # (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project. # within the Gradle project.
# #
# You can find Gradle at https://github.com/gradle/gradle/. # You can find Gradle at https://github.com/gradle/gradle/.
@@ -84,7 +86,7 @@ done
# shellcheck disable=SC2034 # shellcheck disable=SC2034
APP_BASE_NAME=${0##*/} APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value. # Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum MAX_FD=maximum
@@ -203,7 +205,7 @@ fi
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command: # Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped. # and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line. # treated as '${Hostname}' itself on the command line.
Vendored
+2
View File
@@ -13,6 +13,8 @@
@rem See the License for the specific language governing permissions and @rem See the License for the specific language governing permissions and
@rem limitations under the License. @rem limitations under the License.
@rem @rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off @if "%DEBUG%"=="" @echo off
@rem ########################################################################## @rem ##########################################################################