Compare commits

...
51 Commits
Author SHA1 Message Date
Chloe 8dd122631c run shared library initialization on separate context 2026-03-13 23:37:50 -07:00
km7dev 69188075a3 make open folder follow the directory devs specify (#65)
* this feels hacky but it works

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

No files matched your search

+22 -1
View File
@@ -60,8 +60,29 @@ jobs:
mv $APK_PATH $OUT_PATH mv $APK_PATH $OUT_PATH
echo "path=$OUT_PATH" >> $GITHUB_OUTPUT echo "path=$OUT_PATH" >> $GITHUB_OUTPUT
- name: Upload development build - name: Rename mappings
id: rename_mappings
if: ${{ matrix.config.lower == 'release' }}
run: |
SHORT_SHA=${GITHUB_SHA::7}
MAPPING_PATH=${{github.workspace}}/app/build/outputs/mapping/${{ matrix.config.lower }}/mapping.txt
OUT_PATH=${{github.workspace}}/mapping-${{ matrix.config.lower }}-$SHORT_SHA.txt
mv $MAPPING_PATH $OUT_PATH
echo "path=$OUT_PATH" >> $GITHUB_OUTPUT
- name: Upload release build
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
if: ${{ matrix.config.lower == 'release' }}
with:
name: geode-launcher-${{ matrix.config.lower }}
path: |
${{ steps.rename_apk_universal.outputs.path }}
${{ steps.rename_apk.outputs.path }}
${{ steps.rename_mappings.outputs.path }}
- name: Upload debug build
uses: actions/upload-artifact@v4
if: ${{ matrix.config.lower == 'debug' }}
with: with:
name: geode-launcher-${{ matrix.config.lower }} name: geode-launcher-${{ matrix.config.lower }}
path: | path: |
+12 -10
View File
@@ -1,5 +1,3 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
val composeBOM: String by rootProject.extra val composeBOM: String by rootProject.extra
kotlin { kotlin {
@@ -21,8 +19,8 @@ android {
applicationId = "com.geode.launcher" applicationId = "com.geode.launcher"
minSdk = 23 minSdk = 23
targetSdk = 36 targetSdk = 36
versionCode = 23 versionCode = 26
versionName = "1.7.0" versionName = "1.7.3"
vectorDrawables { vectorDrawables {
useSupportLibrary = true useSupportLibrary = true
@@ -31,7 +29,7 @@ android {
@Suppress("UnstableApiUsage") @Suppress("UnstableApiUsage")
externalNativeBuild { externalNativeBuild {
cmake { cmake {
arguments("-DUSE_TULIPHOOK:BOOL=OFF", "-DANDROID_STL=c++_shared") arguments("-DANDROID_STL=c++_shared")
} }
} }
@@ -80,7 +78,7 @@ android {
} }
externalNativeBuild { externalNativeBuild {
cmake { cmake {
version = "3.25.0+" version = "4.0.0+"
path = file("src/main/cpp/CMakeLists.txt") path = file("src/main/cpp/CMakeLists.txt")
} }
} }
@@ -96,17 +94,21 @@ dependencies {
implementation ("androidx.compose.ui:ui-tooling-preview") implementation ("androidx.compose.ui:ui-tooling-preview")
implementation ("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0") implementation ("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
implementation ("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0") implementation ("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
implementation ("androidx.activity:activity-compose:1.12.3") implementation ("androidx.activity:activity-compose:1.12.4")
implementation ("androidx.activity:activity-ktx:1.12.3") implementation ("androidx.activity:activity-ktx:1.12.4")
implementation ("androidx.appcompat:appcompat:1.7.1") implementation ("androidx.appcompat:appcompat:1.7.1")
implementation ("androidx.documentfile:documentfile:1.1.0") implementation ("androidx.documentfile:documentfile:1.1.0")
implementation ("com.squareup.okio:okio:3.16.4") implementation ("com.squareup.okio:okio:3.16.4")
implementation ("com.squareup.okhttp3:okhttp:5.3.2") implementation ("com.squareup.okhttp3:okhttp:5.3.2")
implementation ("com.squareup.okhttp3:okhttp-coroutines:5.3.2")
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0") implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0")
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json-okio:1.10.0") implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json-okio:1.10.0")
implementation ("org.jetbrains.kotlinx:kotlinx-datetime:0.7.1") implementation ("org.jetbrains.kotlinx:kotlinx-datetime:0.7.1")
implementation ("com.mikepenz:multiplatform-markdown-renderer-android:0.39.1")
implementation ("com.mikepenz:multiplatform-markdown-renderer-m3:0.39.1") implementation ("com.mikepenz:multiplatform-markdown-renderer-android:0.39.2")
implementation ("com.mikepenz:multiplatform-markdown-renderer-m3:0.39.2")
implementation ("androidx.browser:browser:1.9.0") implementation ("androidx.browser:browser:1.9.0")
debugImplementation ("androidx.compose.ui:ui-tooling") debugImplementation ("androidx.compose.ui:ui-tooling")
coreLibraryDesugaring ("com.android.tools:desugar_jdk_libs:2.1.5") coreLibraryDesugaring ("com.android.tools:desugar_jdk_libs:2.1.5")
+18 -1
View File
@@ -21,7 +21,6 @@
<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.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.LIGHTS" /> <uses-permission android:name="android.permission.LIGHTS" />
@@ -90,6 +89,24 @@
<data android:scheme="geode-launcher" android:host="crashes" /> <data android:scheme="geode-launcher" android:host="crashes" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name=".preferences.GeodeLogsListingActivity"
android:exported="true"
android:label="@string/title_activity_geode_logs"
android:theme="@style/Theme.GeodeLauncher">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="geode-launcher" android:host="geode-logs" />
</intent-filter>
</activity>
<activity
android:name=".preferences.GeodeLogViewActivity"
android:exported="false"
android:theme="@style/Theme.GeodeLauncher">
</activity>
<activity <activity
android:name=".preferences.ApplicationIconActivity" android:name=".preferences.ApplicationIconActivity"
android:exported="false" android:exported="false"
+8 -38
View File
@@ -1,47 +1,17 @@
cmake_minimum_required(VERSION 3.18.1) cmake_minimum_required(VERSION 4.1.2)
project(launcherfix) project(launcherfix)
add_library(launcherfix SHARED launcher-fix.cpp) add_library(launcherfix SHARED launcher-fix.cpp patcher.cpp)
target_compile_features(launcherfix PUBLIC cxx_std_20) target_compile_features(launcherfix PUBLIC cxx_std_20)
set_target_properties(launcherfix PROPERTIES CXX_EXTENSIONS OFF) set_target_properties(launcherfix PROPERTIES CXX_EXTENSIONS OFF)
include(FetchContent) set_target_properties(launcherfix PROPERTIES
INTERPROCEDURAL_OPTIMIZATION ON
option(USE_TULIPHOOK "enables choice of hooking library" OFF) UNITY_BUILD ON
if (USE_TULIPHOOK) C_VISIBILITY_PRESET hidden
FetchContent_Declare(TulipHook CXX_VISIBILITY_PRESET hidden
GIT_REPOSITORY https://github.com/geode-sdk/TulipHook.git )
GIT_TAG v3.1.4
)
set(TULIP_LINK_SOURCE ON CACHE INTERNAL "")
FetchContent_MakeAvailable(TulipHook)
target_compile_definitions(launcherfix PRIVATE -DUSE_TULIPHOOK=1)
target_link_libraries(launcherfix PRIVATE TulipHook TulipHookInclude)
else()
FetchContent_Declare(dobby
# this is the last version that builds on android at all
GIT_REPOSITORY https://github.com/jmpews/Dobby
GIT_TAG 0932d69c320e786672361ab53825ba8f4245e9d3
)
FetchContent_GetProperties(dobby)
if(NOT dobby_POPULATED)
FetchContent_Populate(dobby)
# fixes Dobby build
# add_compile_definitions(typeof=__typeof__)
set(DOBBY_DEBUG OFF CACHE INTERNAL "" FORCE)
set(DOBBY_GENERATE_SHARED OFF CACHE INTERNAL "" FORCE)
add_subdirectory("${dobby_SOURCE_DIR}" ${dobby_BINARY_DIR} EXCLUDE_FROM_ALL)
target_include_directories(launcherfix PRIVATE ${dobby_SOURCE_DIR}/include)
target_link_libraries(launcherfix PRIVATE dobby)
endif()
endif()
target_link_libraries(launcherfix PRIVATE log) target_link_libraries(launcherfix PRIVATE log)
+41
View File
@@ -0,0 +1,41 @@
#pragma once
#include <string>
#include <array>
#include <string_view>
#include <link.h>
class DataPaths {
public:
static std::string original_data_path;
static std::string data_path;
static bool patch_exceptions;
};
// this is every function that i thought would be relevant
constexpr std::array<const std::string_view, 12> remove_symbols{
"__gxx_personality_v0",
"__cxa_throw",
"__cxa_rethrow",
"__cxa_allocate_exception",
"__cxa_end_catch",
"__cxa_begin_catch",
"__cxa_guard_abort",
"__cxa_guard_acquire",
"__cxa_guard_release",
"__cxa_free_exception",
"__emutls_get_address", // added in 2.208
"__cxa_thread_atexit"
// android 10's libc doesn't have these symbols
// and the ndk is forbidden from exporting them!! so keep it for old mods...
// "_Unwind_RaiseException",
// "_Unwind_Resume"
};
FILE* fopen_hook(const char* pathname, const char* mode);
int rename_hook(const char* old_path, const char* new_path);
int on_dl_iterate(dl_phdr_info* info, size_t size, void* data);
+30 -300
View File
@@ -1,36 +1,13 @@
#include <dlfcn.h>
#include <jni.h> #include <jni.h>
#include <fstream>
#include <string> #include <string>
#include <vector>
#include <cstdint>
#include <android/log.h>
#include <link.h>
#ifndef DISABLE_LAUNCHER_FIX #include "base.h"
#include "log.hpp"
#ifdef USE_TULIPHOOK std::string DataPaths::original_data_path{};
#include <tulip/TulipHook.hpp> std::string DataPaths::data_path{};
#else
#include <dobby.h>
#endif
#endif bool DataPaths::patch_exceptions{};
class DataPaths {
public:
std::string original_data_path{};
std::string data_path{};
std::string load_symbols_from{};
static DataPaths& get_instance() {
static auto paths_instance = DataPaths();
return paths_instance;
}
private:
DataPaths() {}
};
extern "C" extern "C"
JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setDataPath( JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setDataPath(
@@ -41,25 +18,11 @@ JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setDataPath(
auto is_copy = jboolean(); auto is_copy = jboolean();
auto data_path_str = env->GetStringUTFChars(data_path, &is_copy); auto data_path_str = env->GetStringUTFChars(data_path, &is_copy);
DataPaths::get_instance().data_path = std::string(data_path_str); DataPaths::data_path = std::string(data_path_str);
env->ReleaseStringUTFChars(data_path, data_path_str); env->ReleaseStringUTFChars(data_path, data_path_str);
} }
extern "C"
JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_enableCustomSymbolList(
JNIEnv* env,
jobject,
jstring symbol_path
) {
auto is_copy = jboolean();
auto symbol_path_str = env->GetStringUTFChars(symbol_path, &is_copy);
DataPaths::get_instance().load_symbols_from = std::string(symbol_path_str);
env->ReleaseStringUTFChars(symbol_path, symbol_path_str);
}
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,
@@ -69,227 +32,52 @@ JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setOriginalDataPath(
auto is_copy = jboolean(); auto is_copy = jboolean();
auto data_path_str = env->GetStringUTFChars(data_path, &is_copy); auto data_path_str = env->GetStringUTFChars(data_path, &is_copy);
DataPaths::get_instance().original_data_path = std::string(data_path_str); DataPaths::original_data_path = std::string(data_path_str);
env->ReleaseStringUTFChars(data_path, data_path_str); env->ReleaseStringUTFChars(data_path, data_path_str);
} }
// i copied this function from the android linker extern "C"
std::uint32_t elfhash(const char *_name) { JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_enableExceptionsRenaming(JNIEnv*, jobject) {
const unsigned char *name = reinterpret_cast<const unsigned char*>(_name); DataPaths::patch_exceptions = true;
std::uint32_t h = 0, g;
while (*name) {
h = (h << 4) + *name++;
g = h & 0xf0000000;
h ^= g;
h ^= g >> 24;
}
return h;
}
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 // this should be called after gd is loaded but before geode
extern "C" extern "C" JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_performPatches(JNIEnv*, jobject) {
JNIEXPORT int JNICALL Java_com_geode_launcher_LauncherFix_performExceptionsRenaming(JNIEnv*, jobject) { dl_iterate_phdr(on_dl_iterate, nullptr);
return dl_iterate_phdr(on_dl_iterate, nullptr);
} }
std::optional<std::string> redirect_path(const char* pathname) { std::optional<std::string> redirect_path(const char* pathname) {
auto& data_path = DataPaths::get_instance().data_path; std::string_view data_path = DataPaths::data_path;
auto& original_data_path = DataPaths::get_instance().original_data_path; std::string_view original_data_path = DataPaths::original_data_path;
if (!data_path.empty() && !original_data_path.empty()) { if (data_path.empty() || original_data_path.empty()) [[unlikely]] {
// call this a c string optimization return std::nullopt;
if (std::strncmp(pathname, original_data_path.c_str(), original_data_path.size()) == 0) { }
auto path = data_path + (pathname + original_data_path.size());
return path; // call this a c string optimization
} if (std::strncmp(pathname, original_data_path.data(), original_data_path.size()) == 0) {
auto remaining_path = (pathname + original_data_path.size());
auto remaining_len = strlen(remaining_path);
std::string x;
x.reserve(data_path.size() + remaining_len);
x.append(data_path);
x.append(remaining_path, remaining_len);
return x;
} }
return std::nullopt; return std::nullopt;
} }
FILE* (*fopen_original)(const char *pathname, const char *mode);
FILE* fopen_hook(const char* pathname, const char* mode) { FILE* fopen_hook(const char* pathname, const char* mode) {
auto path_str = redirect_path(pathname); auto path_str = redirect_path(pathname);
auto redirect = path_str.has_value() ? path_str->c_str() : pathname; auto redirect = path_str.has_value() ? path_str->c_str() : pathname;
return fopen_original(redirect, mode); return fopen(redirect, mode);
} }
int (*rename_original)(const char* old_path, const char* new_path);
int rename_hook(const char* old_path, const char* new_path) { int rename_hook(const char* old_path, const char* new_path) {
auto old_path_str = redirect_path(old_path); auto old_path_str = redirect_path(old_path);
auto old_redirect = old_path_str ? old_path_str->c_str() : old_path; auto old_redirect = old_path_str ? old_path_str->c_str() : old_path;
@@ -297,63 +85,5 @@ int rename_hook(const char* old_path, const char* new_path) {
auto new_path_str = redirect_path(new_path); auto new_path_str = redirect_path(new_path);
auto new_redirect = new_path_str ? new_path_str->c_str() : new_path; auto new_redirect = new_path_str ? new_path_str->c_str() : new_path;
return rename_original(old_redirect, new_redirect); return rename(old_redirect, new_redirect);
}
bool hook_function(void* addr, auto* hook, auto** orig) {
#ifdef USE_TULIPHOOK
using namespace tulip::hook;
HandlerMetadata metadata = {
std::make_shared<PlatformConvention>(),
AbstractFunction::from(hook)
};
auto handler_result = createHandler(addr, metadata);
if (!handler_result) {
__android_log_print(ANDROID_LOG_WARN, "GeodeLauncher-Fix",
"failed to create handler: %s\n", handler_result.unwrapErr().c_str());
return false;
}
auto handler = *handler_result;
HookMetadata hook_metadata = {0};
createHook(handler, reinterpret_cast<void*>(hook), hook_metadata);
WrapperMetadata wrapper_metadata = {
std::make_unique<PlatformConvention>(),
AbstractFunction::from(hook)
};
auto wrapper = createWrapper(addr, std::move(wrapper_metadata));
if (!wrapper) {
__android_log_print(ANDROID_LOG_WARN, "GeodeLauncher-Fix",
"failed to create wrapper: %s\n", wrapper.unwrapErr().c_str());
return false;
}
*reinterpret_cast<void**>(orig) = *wrapper;
return true;
#else
DobbyHook(
addr,
reinterpret_cast<dobby_dummy_func_t>(hook),
reinterpret_cast<dobby_dummy_func_t*>(orig)
);
return true;
#endif
}
[[gnu::constructor]] [[gnu::used]] void setup_hooks() {
#ifndef DISABLE_LAUNCHER_FIX
auto fopen_addr = dlsym(RTLD_NEXT, "fopen");
hook_function(fopen_addr, &fopen_hook, &fopen_original);
auto rename_addr = dlsym(RTLD_NEXT, "rename");
hook_function(rename_addr, &rename_hook, &rename_original);
#endif
} }
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#define LOG_TAG "GeodeLauncher-Fix"
#include <android/log.h>
#include <format>
namespace log {
template<typename... Args>
void log_internal(android_LogPriority prio, std::format_string<Args...> fmt, Args&&... args) {
std::string str = std::format(fmt, std::forward<Args>(args)...);
__android_log_write(prio, LOG_TAG, str.c_str());
}
template<typename... Args>
inline void debug(std::format_string<Args...> fmt, Args&&... args) {
log_internal(ANDROID_LOG_DEBUG, fmt, std::forward<Args>(args)...);
}
template<typename... Args>
inline void info(std::format_string<Args...> fmt, Args&&... args) {
log_internal(ANDROID_LOG_INFO, fmt, std::forward<Args>(args)...);
}
template<typename... Args>
inline void warn(std::format_string<Args...> fmt, Args&&... args) {
log_internal(ANDROID_LOG_WARN, fmt, std::forward<Args>(args)...);
}
template<typename... Args>
inline void error(std::format_string<Args...> fmt, Args&&... args) {
log_internal(ANDROID_LOG_ERROR, fmt, std::forward<Args>(args)...);
}
}
+303
View File
@@ -0,0 +1,303 @@
#include <cstdint>
#include <span>
#include <link.h>
#include <unistd.h>
#include <sys/mman.h>
#include "log.hpp"
#include "base.h"
struct ElfDynamicState {
std::uint32_t* hash_table{};
char* str_table{};
ElfW(Sym)* sym_table{};
void* plt_table{};
std::uint64_t plt_table_size_bytes{};
bool plt_table_rela{};
std::uint64_t soname_idx{};
bool is_empty() const {
return !hash_table || !str_table || !sym_table || !plt_table;
}
};
// copied from tuliphook source :)
// tuliphook sets WRX perms, but we write to pages with W^X protection
bool set_mprotect(std::uintptr_t address, size_t size, int protection) {
auto const page_size = getpagesize();
auto const page_mask = page_size - 1;
auto const ptr = reinterpret_cast<uintptr_t>(address);
auto const aligned_ptr = ptr & (~page_mask);
auto const begin_size = ptr - aligned_ptr;
auto const page_count = (begin_size + size + page_mask) / page_size;
auto const aligned_size = page_count * page_size;
auto status = mprotect(reinterpret_cast<void*>(aligned_ptr), aligned_size, protection);
return status == 0;
}
// i copied this function from the android linker
constexpr std::uint32_t elfhash(std::string_view name) {
std::uint32_t h = 0, g;
for (const auto c : name) {
h = (h << 4) + static_cast<unsigned char>(c);
g = h & 0xf0000000;
h ^= g;
h ^= g >> 24;
}
return h;
}
constexpr std::array<const std::uint8_t, 3> SYMBOL_PATCH_VALUE{":3"};
bool patch_symbol(std::uint32_t* hash_table, char* str_table, ElfW(Sym)* sym_table, const std::string_view orig_name) {
auto hash = elfhash(orig_name);
auto nbucket = hash_table[0];
auto nchain = hash_table[1];
auto bucket = &hash_table[2];
auto chain = &bucket[nbucket];
for (auto i = bucket[hash % nbucket]; i != 0; i = chain[i]) {
auto sym = sym_table + i;
auto sym_name = str_table + sym->st_name;
if (orig_name == sym_name) {
// there's probably no point to checking this, honestly
if (ELF_ST_BIND(sym->st_info) == STB_GLOBAL && ELF_ST_TYPE(sym->st_info) == STT_FUNC) {
// we found it! now go rename the symbol
log::debug("Disabling symbol at {} ({})", reinterpret_cast<void*>(sym_name), sym_name);
if (!set_mprotect(reinterpret_cast<std::uintptr_t>(sym_name), SYMBOL_PATCH_VALUE.size(), PROT_READ | PROT_WRITE)) {
log::warn("failed to patch symbol: could not set writable flag");
return false;
}
memcpy(sym_name, SYMBOL_PATCH_VALUE.data(), SYMBOL_PATCH_VALUE.size());
if (!set_mprotect(reinterpret_cast<std::uintptr_t>(sym_name), SYMBOL_PATCH_VALUE.size(), PROT_READ)) {
log::warn("failed to patch symbol: could not remove writable flag");
return false;
}
return true;
}
}
}
log::warn("could not find symbol {} to patch (hash: {})", orig_name, hash);
return false;
}
bool perform_symbol_patches(dl_phdr_info* info, const ElfDynamicState& state) {
// patch symbol names
auto has_error = false;
for (const auto symbol : remove_symbols) {
if (!patch_symbol(state.hash_table, state.str_table, state.sym_table, symbol)) {
has_error = true;
}
}
return !has_error;
}
#if defined(__LP64__)
#define ELF_R_SYM(i) ELF64_R_SYM(i)
#define ELF_R_TYPE(i) ELF64_R_TYPE(i)
#else
#define ELF_R_SYM(i) ELF32_R_SYM(i)
#define ELF_R_TYPE(i) ELF32_R_TYPE(i)
#endif
template <typename T, typename... Types>
concept is_one_of = (std::same_as<T, Types> || ...);
template <typename T> requires is_one_of<T, ElfW(Rela), ElfW(Rel)>
bool rewrite_symbol_reloc(std::uintptr_t addr, T& r, ElfW(Sym)* sym, void* func) {
auto type = ELF_R_TYPE(r.r_info);
// we're only interested in JUMP_SLOT symbols
if (type != R_AARCH64_JUMP_SLOT && type != R_ARM_JUMP_SLOT) {
log::warn("asked to patch unknown symbol type: {:#x}", type);
return false;
}
auto write_addr = reinterpret_cast<std::uintptr_t*>(addr);
auto val = reinterpret_cast<std::uintptr_t>(func);
if constexpr (std::is_same_v<T, ElfW(Rela)>) {
// reloc = S + A
val += r.r_addend;
}
log::debug("Patching relocation at vaddr {:#x} (offset={:#x}): {:#x} => {:#x}", addr, r.r_offset, *write_addr, val);
if (!set_mprotect(addr, sizeof(std::uintptr_t), PROT_WRITE | PROT_READ)) {
log::warn("Failed to patch relocation: failed to add writable flag");
return false;
}
// why use memcpy when you can do this :)
*write_addr = val;
if (!set_mprotect(addr, sizeof(std::uintptr_t), PROT_READ)) {
log::warn("Failed to patch relocation: failed to remove writable flag");
return false;
}
return true;
}
template <typename T> requires is_one_of<T, ElfW(Rela), ElfW(Rel)>
bool perform_reloc_patches(dl_phdr_info* info, const ElfDynamicState& state) {
auto table_size = state.plt_table_size_bytes / sizeof(T);
auto failed = false;
for (auto& plt : std::span{
reinterpret_cast<T*>(state.plt_table),
static_cast<std::size_t>(table_size)
}) {
auto sym = ELF_R_SYM(plt.r_info);
if (sym == 0) continue;
auto sym_data = state.sym_table + sym;
auto sym_name = state.str_table + sym_data->st_name;
auto reloc_addr = info->dlpi_addr + plt.r_offset;
if (strcmp(sym_name, "fopen") == 0) {
if (!rewrite_symbol_reloc(reloc_addr, plt, sym_data, reinterpret_cast<void*>(&fopen_hook))) {
failed = true;
}
} else if (strcmp(sym_name, "rename") == 0) {
if (!rewrite_symbol_reloc(reloc_addr, plt, sym_data, reinterpret_cast<void*>(&rename_hook))) {
failed = true;
}
}
}
return !failed;
}
int on_dl_iterate(dl_phdr_info* info, size_t size, void* data) {
// this is probably going to be gd
if (info->dlpi_name != nullptr && strstr(info->dlpi_name, "libcocos2dcpp.so") != nullptr) {
log::debug("Base address of libcocos2dcpp: {:#x}", info->dlpi_addr);
// step 1: get the dynamic table
std::uintptr_t dyn_addr = 0u;
for (auto& phdr : std::span{ info->dlpi_phdr, info->dlpi_phnum}) {
if (phdr.p_type == PT_DYNAMIC) {
dyn_addr = info->dlpi_addr + phdr.p_vaddr;
break;
}
}
if (dyn_addr == 0u) {
log::warn("failed to find libcocos dynamic section");
return 0;
}
// step 2: get the symbol table
auto dyn_entry = reinterpret_cast<ElfW(Dyn)*>(dyn_addr);
auto dyn_end_reached = false;
ElfDynamicState state{};
while (!dyn_end_reached) {
auto tag = dyn_entry->d_tag;
switch (dyn_entry->d_tag) {
case DT_SYMTAB:
state.sym_table = reinterpret_cast<ElfW(Sym)*>(info->dlpi_addr + dyn_entry->d_un.d_val);
break;
case DT_STRTAB:
state.str_table = reinterpret_cast<char*>(info->dlpi_addr + dyn_entry->d_un.d_val);
break;
case DT_HASH:
state.hash_table = reinterpret_cast<std::uint32_t*>(info->dlpi_addr + dyn_entry->d_un.d_val);
break;
case DT_SONAME:
state.soname_idx = dyn_entry->d_un.d_val;
break;
case DT_JMPREL:
state.plt_table = reinterpret_cast<void*>(info->dlpi_addr + dyn_entry->d_un.d_val);
break;
case DT_PLTREL: {
// for Android, arm64 = DT_RELA, arm32 = DT_REL
// could just gate this based on architecture but whatever
auto val = dyn_entry->d_un.d_val;
state.plt_table_rela = val == DT_RELA;
if (val != DT_RELA && val != DT_REL) {
log::warn("unrecognized PLTREL type {:#x}", val);
}
break;
}
case DT_PLTRELSZ:
state.plt_table_size_bytes = dyn_entry->d_un.d_val;
break;
case DT_NULL:
dyn_end_reached = true;
break;
}
dyn_entry++;
}
if (state.is_empty()) {
log::error("failed to parse dynamic section (at least one table is null)");
return -1;
}
bool failed = false;
auto so_name = state.str_table + state.soname_idx;
if (strcmp(so_name, "libcocos2dcpp.so") != 0) {
log::warn("DT_SONAME gives us {}! that's not good", so_name);
failed = true;
}
auto reloc_failed = state.plt_table_rela
? !perform_reloc_patches<ElfW(Rela)>(info, state)
: !perform_reloc_patches<ElfW(Rel)>(info, state);
if (reloc_failed) {
failed = true;
}
if (DataPaths::patch_exceptions) {
if (!perform_symbol_patches(info, state)) {
failed = true;
}
}
if (failed) {
log::info(
"symbol patch diagnostics\nlibrary path: {}\naddrs: base={:#x} sym={} str={} hash={} plt={}",
info->dlpi_name,
info->dlpi_addr,
reinterpret_cast<void*>(state.sym_table),
reinterpret_cast<void*>(state.str_table),
reinterpret_cast<void*>(state.hash_table),
state.plt_table
);
}
return 1;
}
return 0;
}
@@ -24,8 +24,10 @@ import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.WindowInsetsControllerCompat
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope
import com.customRobTop.BaseRobTopActivity import com.customRobTop.BaseRobTopActivity
import com.customRobTop.JniToCpp import com.customRobTop.JniToCpp
import com.geode.launcher.main.FullScreenLoadingIndicator
import com.geode.launcher.main.LaunchNotification import com.geode.launcher.main.LaunchNotification
import com.geode.launcher.main.determineDisplayedCards import com.geode.launcher.main.determineDisplayedCards
import com.geode.launcher.utils.Constants import com.geode.launcher.utils.Constants
@@ -35,13 +37,14 @@ 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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.cocos2dx.lib.Cocos2dxGLSurfaceView import org.cocos2dx.lib.Cocos2dxGLSurfaceView
import org.cocos2dx.lib.Cocos2dxHelper import org.cocos2dx.lib.Cocos2dxHelper
import org.cocos2dx.lib.Cocos2dxRenderer import org.cocos2dx.lib.Cocos2dxRenderer
import org.fmod.FMOD import org.fmod.FMOD
import java.io.File import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import javax.microedition.khronos.egl.EGL10 import javax.microedition.khronos.egl.EGL10
import javax.microedition.khronos.egl.EGLConfig import javax.microedition.khronos.egl.EGLConfig
@@ -91,103 +94,88 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
return return
} }
try { val rootLayout = setupContext()
createVersionFile() val loadingView = ComposeView(this).apply {
tryLoadGame() setContent {
} catch (e: UnsatisfiedLinkError) { FullScreenLoadingIndicator()
Log.e("GeodeLauncher", "Library linkage failure", e) }
}
// generates helpful information for use in debugging library load failures if (rootLayout != null) {
val gdPackageInfo = packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0) rootLayout.addView(loadingView)
val abiMismatch = GamePackageUtils.detectAbiMismatch(this, gdPackageInfo, e) } else {
setContentView(loadingView)
}
val is64bit = LaunchUtils.is64bit lifecycleScope.launch {
val errorMessage = when { val error = preloadGame(rootLayout)
abiMismatch && is64bit -> LaunchUtils.LauncherError.LINKER_NEEDS_32BIT if (error != null) {
abiMismatch -> LaunchUtils.LauncherError.LINKER_NEEDS_64BIT returnToMain(error)
e.message?.contains("__gxx_personality_v0") == true ->
LaunchUtils.LauncherError.LINKER_FAILED_STL
else -> LaunchUtils.LauncherError.LINKER_FAILED
} }
returnToMain(errorMessage, e.message, e.stackTraceToString()) rootLayout?.removeView(loadingView)
return
} catch (e: Exception) {
Log.e("GeodeLauncher", "Uncaught error during game load", e)
returnToMain(
LaunchUtils.LauncherError.GENERIC,
e.message ?: "Unknown Exception",
e.stackTraceToString()
)
return
} }
}
suspend fun preloadGame(rootLayout: FrameLayout?): LaunchUtils.FullLauncherError? = try {
createVersionFile()
val loadFailed = tryLoadGame()
val baseView = createView()
if (rootLayout != null) {
rootLayout.addView(baseView)
} else {
setContentView(baseView)
}
mGLSurfaceView?.manualBackEvents = true
setupNotificationView(baseView, loadFailed)
null
} catch (e: UnsatisfiedLinkError) {
Log.e("GeodeLauncher", "Library linkage failure", e)
// generates helpful information for use in debugging library load failures
val gdPackageInfo = packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0)
val abiMismatch = GamePackageUtils.detectAbiMismatch(this, gdPackageInfo, e)
val is64bit = LaunchUtils.is64bit
val errorMessage = when {
abiMismatch && is64bit -> LaunchUtils.LauncherError.LINKER_NEEDS_32BIT
abiMismatch -> LaunchUtils.LauncherError.LINKER_NEEDS_64BIT
e.message?.contains("__gxx_personality_v0") == true ->
LaunchUtils.LauncherError.LINKER_FAILED_STL
else -> LaunchUtils.LauncherError.LINKER_FAILED
}
LaunchUtils.FullLauncherError(
errorMessage,
e.message ?: "Unknown exception",
e.stackTraceToString()
)
} catch (e: Exception) {
Log.e("GeodeLauncher", "Uncaught error during game load", e)
LaunchUtils.FullLauncherError(
LaunchUtils.LauncherError.GENERIC,
e.message ?: "Unknown Exception",
e.stackTraceToString()
)
}
private fun setupContext(): FrameLayout? {
Cocos2dxHelper.init(this, this)
GeodeUtils.setContext(this)
GeodeUtils.setCapabilityListener(this)
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() { override fun handleOnBackPressed() {
mGLSurfaceView?.sendKeyBack() 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(
error: LaunchUtils.LauncherError? = null,
returnMessage: String? = null,
returnExtendedMessage: String? = null
) {
val launchIntent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
if (error != null && !returnMessage.isNullOrEmpty()) {
putExtra(LaunchUtils.LAUNCHER_KEY_RETURN_ERROR, error)
putExtra(LaunchUtils.LAUNCHER_KEY_RETURN_MESSAGE, returnMessage)
putExtra(LaunchUtils.LAUNCHER_KEY_RETURN_EXTENDED_MESSAGE, returnExtendedMessage)
}
}
startActivity(launchIntent)
}
private fun tryLoadGame() {
val gdPackageInfo = packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0)
setupRedirection(gdPackageInfo)
Cocos2dxHelper.init(this, this)
GeodeUtils.setContext(this)
GeodeUtils.setCapabilityListener(this)
tryLoadLibrary(gdPackageInfo, Constants.FMOD_LIB_NAME)
tryLoadLibrary(gdPackageInfo, Constants.COCOS_LIB_NAME)
if (GamePackageUtils.getGameVersionCode(packageManager) >= 39L) {
/*
val customSymbols = PreferenceUtils.get(this).getBoolean(PreferenceUtils.Key.CUSTOM_SYMBOL_LIST)
if (customSymbols) {
val symbolFile = File(LaunchUtils.getBaseDirectory(this), "exception_symbols.txt")
LauncherFix.enableCustomSymbolList(symbolFile.path)
}
*/
// 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()
val baseView = createView()
if (!GeodeUtils.handleSafeArea && Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) { if (!GeodeUtils.handleSafeArea && Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
val frameLayoutParams = ViewGroup.LayoutParams( val frameLayoutParams = ViewGroup.LayoutParams(
@@ -210,20 +198,81 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
WindowInsets.CONSUMED WindowInsets.CONSUMED
} }
frameLayout.addView(baseView)
setContentView(frameLayout) setContentView(frameLayout)
} else { return frameLayout
setContentView(baseView)
} }
return null
}
private suspend fun createVersionFile() = runCatching {
val gameVersion = GamePackageUtils.getGameVersionCode(packageManager)
val versionPath = File(filesDir, "game_version.txt")
withContext(Dispatchers.IO) {
if (versionPath.exists()) {
versionPath.delete()
}
versionPath.writeText("$gameVersion")
}
}
private fun returnToMain(message: LaunchUtils.FullLauncherError? = null, ) {
val launchIntent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
if (message != null) {
putExtra(LaunchUtils.LAUNCHER_KEY_RETURN_ERROR, message.error)
putExtra(LaunchUtils.LAUNCHER_KEY_RETURN_MESSAGE, message.returnMessage)
putExtra(LaunchUtils.LAUNCHER_KEY_RETURN_EXTENDED_MESSAGE, message.extendedMessage)
}
}
startActivity(launchIntent)
}
private suspend fun tryLoadGame(): Boolean {
val gdPackageInfo = packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0)
setupRedirection(gdPackageInfo)
tryLoadLibrary(gdPackageInfo, Constants.FMOD_LIB_NAME)
tryLoadLibrary(gdPackageInfo, Constants.COCOS_LIB_NAME)
if (GamePackageUtils.getGameVersionCode(packageManager) >= 39L) {
// this fix requires geode v3, which is 2.206+
// there is a short period in which 2.206 users will still have geode v2, but whatever. ig
LauncherFix.enableExceptionsRenaming()
}
LauncherFix.performPatches()
loadInternalMods()
setupPostLibraryLoad(gdPackageInfo) setupPostLibraryLoad(gdPackageInfo)
try { return try {
loadGeodeLibrary() loadGeodeLibrary()
false
} catch (e: UnsatisfiedLinkError) { } catch (e: UnsatisfiedLinkError) {
handleGeodeException(e) handleGeodeException(e)
true
} catch (e: Exception) { } catch (e: Exception) {
handleGeodeException(e) handleGeodeException(e)
true
}
}
private fun setupNotificationView(baseView: FrameLayout, geodeFailed: Boolean) {
val cards = determineDisplayedCards(this, geodeFailed)
if (cards.isNotEmpty()) {
val notificationView = ComposeView(this)
baseView.addView(notificationView)
notificationView.setContent {
LaunchNotification(cards)
}
} }
} }
@@ -272,7 +321,7 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
} }
@SuppressLint("UnsafeDynamicallyLoadedCode") @SuppressLint("UnsafeDynamicallyLoadedCode")
private fun tryLoadLibrary(packageInfo: PackageInfo, libraryName: String) { private suspend fun tryLoadLibrary(packageInfo: PackageInfo, libraryName: String) {
val nativeDir = getNativeLibraryDirectory(packageInfo.applicationInfo!!) val nativeDir = getNativeLibraryDirectory(packageInfo.applicationInfo!!)
val libraryPath = if (nativeDir.endsWith('/')) "${nativeDir}lib$libraryName.so" else "$nativeDir/lib$libraryName.so" val libraryPath = if (nativeDir.endsWith('/')) "${nativeDir}lib$libraryName.so" else "$nativeDir/lib$libraryName.so"
@@ -291,7 +340,7 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
} }
@SuppressLint("UnsafeDynamicallyLoadedCode") @SuppressLint("UnsafeDynamicallyLoadedCode")
private fun loadLibraryCopy(libraryName: String, libraryPath: String) { private suspend fun loadLibraryCopy(libraryName: String, libraryPath: String) {
if (libraryPath.contains("!/")) { if (libraryPath.contains("!/")) {
// library in apk can't be loaded directly // library in apk can't be loaded directly
loadLibraryFromAssetsCopy(libraryName) loadLibraryFromAssetsCopy(libraryName)
@@ -301,11 +350,7 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
val library = File(libraryPath) val library = File(libraryPath)
val libraryCopy = File(cacheDir, "lib$libraryName.so") val libraryCopy = File(cacheDir, "lib$libraryName.so")
libraryCopy.outputStream().use { libraryOutput -> DownloadUtils.copyFile(library, libraryCopy)
library.inputStream().use { inputStream ->
DownloadUtils.copyFile(inputStream, libraryOutput)
}
}
System.load(libraryCopy.path) System.load(libraryCopy.path)
@@ -313,7 +358,7 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
} }
@SuppressLint("UnsafeDynamicallyLoadedCode") @SuppressLint("UnsafeDynamicallyLoadedCode")
private fun loadLibraryFromAssetsCopy(libraryName: String) { private suspend fun loadLibraryFromAssetsCopy(libraryName: String) {
// loads a library loaded in assets // loads a library loaded in assets
// this copies the library to a non-compressed directory // this copies the library to a non-compressed directory
@@ -328,9 +373,11 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
// there doesn't seem to be a way to load a library from a file descriptor // there doesn't seem to be a way to load a library from a file descriptor
val libraryCopy = File(cacheDir, "lib$libraryName.so") val libraryCopy = File(cacheDir, "lib$libraryName.so")
libraryCopy.outputStream().use { libraryOutput -> withContext(Dispatchers.IO) {
libraryFd.createInputStream().use { inputStream -> libraryCopy.outputStream().use { libraryOutput ->
DownloadUtils.copyFile(inputStream, libraryOutput) libraryFd.createInputStream().use { inputStream ->
DownloadUtils.copyFile(inputStream, libraryOutput)
}
} }
} }
@@ -365,7 +412,7 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
} }
@SuppressLint("UnsafeDynamicallyLoadedCode") @SuppressLint("UnsafeDynamicallyLoadedCode")
private fun loadGeodeLibrary() { private suspend fun loadGeodeLibrary() {
// Load Geode if exists // Load Geode if exists
// bundling the object with the application allows for nicer backtraces // bundling the object with the application allows for nicer backtraces
try { try {
@@ -386,22 +433,23 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
// also i get 20 million permission denied errors // also i get 20 million permission denied errors
val externalGeodePath = LaunchUtils.getInstalledGeodePath(this)!! val externalGeodePath = LaunchUtils.getInstalledGeodePath(this)!!
val copiedPath = File(filesDir.path, "copied") val copiedPath = File(filesDir, "copied")
if (copiedPath.exists()) { if (copiedPath.exists()) {
copiedPath.deleteRecursively() copiedPath.deleteRecursively()
} }
copiedPath.mkdir() copiedPath.mkdir()
val copiedGeodePath = File(copiedPath.path, "Geode.so") val copiedGeodePath = File(copiedPath, "Geode.so")
if (externalGeodePath.exists()) { if (externalGeodePath.exists()) {
DownloadUtils.copyFile( DownloadUtils.copyFile(
FileInputStream(externalGeodePath), externalGeodePath,
FileOutputStream(copiedGeodePath) copiedGeodePath
) )
if (copiedGeodePath.exists()) { if (copiedGeodePath.exists()) {
println("Loading Geode from ${externalGeodePath.name}") println("Loading Geode from ${externalGeodePath.name}")
System.load(copiedGeodePath.path) System.load(copiedGeodePath.path)
return return
} }
@@ -444,19 +492,6 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
this.mGLSurfaceView = glSurfaceView this.mGLSurfaceView = glSurfaceView
frameLayout.addView(this.mGLSurfaceView) frameLayout.addView(this.mGLSurfaceView)
val disableNotification = PreferenceUtils.get(this).getBoolean(PreferenceUtils.Key.DISABLE_REDESIGN)
if (!disableNotification) {
val hasCards = determineDisplayedCards(this)
if (hasCards.isNotEmpty()) {
val notificationView = ComposeView(this)
frameLayout.addView(notificationView)
notificationView.setContent {
LaunchNotification()
}
}
}
glSurfaceView.setEGLContextClientVersion(2) glSurfaceView.setEGLContextClientVersion(2)
glSurfaceView.setEGLConfigChooser(EGLConfigChooser()) glSurfaceView.setEGLConfigChooser(EGLConfigChooser())
@@ -13,7 +13,7 @@ object LauncherFix {
external fun setOriginalDataPath(dataPath: String) external fun setOriginalDataPath(dataPath: String)
external fun performExceptionsRenaming() external fun enableExceptionsRenaming()
external fun enableCustomSymbolList(symbolsPath: String) external fun performPatches()
} }
@@ -151,6 +151,8 @@ class MainActivity : ComponentActivity() {
launchViewModel.launchArguments = launchArguments launchViewModel.launchArguments = launchArguments
} }
launchViewModel.beginLaunchFlow()
CompositionLocalProvider(LocalTheme provides theme) { CompositionLocalProvider(LocalTheme provides theme) {
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) { GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
Surface( Surface(
@@ -176,6 +178,7 @@ fun LaunchCancelledIcon(cancelReason: LaunchViewModel.LaunchCancelReason, modifi
LaunchViewModel.LaunchCancelReason.GAME_OUTDATED, LaunchViewModel.LaunchCancelReason.GAME_OUTDATED,
LaunchViewModel.LaunchCancelReason.GAME_MISSING -> Icon(painterResource(R.drawable.icon_error), contentDescription = null, modifier) LaunchViewModel.LaunchCancelReason.GAME_MISSING -> Icon(painterResource(R.drawable.icon_error), contentDescription = null, modifier)
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED, LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED,
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED_AUTOMATIC,
LaunchViewModel.LaunchCancelReason.GEODE_NOT_FOUND -> Icon(painterResource(R.drawable.icon_warning), contentDescription = null, modifier) LaunchViewModel.LaunchCancelReason.GEODE_NOT_FOUND -> Icon(painterResource(R.drawable.icon_warning), contentDescription = null, modifier)
else -> Icon(painterResource(R.drawable.icon_info), contentDescription = null, modifier) else -> Icon(painterResource(R.drawable.icon_info), contentDescription = null, modifier)
} }
@@ -187,6 +190,9 @@ fun mapCancelReasonToInfo(cancelReason: LaunchViewModel.LaunchCancelReason): Lau
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED -> LaunchStatusInfo( LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED -> LaunchStatusInfo(
title = stringResource(R.string.launcher_cancelled_crash) title = stringResource(R.string.launcher_cancelled_crash)
) )
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED_AUTOMATIC -> LaunchStatusInfo(
title = stringResource(R.string.launcher_cancelled_crash_instant)
)
LaunchViewModel.LaunchCancelReason.GEODE_NOT_FOUND -> LaunchStatusInfo( LaunchViewModel.LaunchCancelReason.GEODE_NOT_FOUND -> LaunchStatusInfo(
title = stringResource(R.string.geode_download_title) title = stringResource(R.string.geode_download_title)
) )
@@ -323,6 +329,7 @@ fun RetryButtonContents(reason: LaunchViewModel.LaunchCancelReason) {
Spacer(Modifier.size(ButtonDefaults.IconSpacing)) Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_cancelled_resume)) Text(stringResource(R.string.launcher_cancelled_resume))
} }
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED_AUTOMATIC,
LaunchViewModel.LaunchCancelReason.MANUAL, LaunchViewModel.LaunchCancelReason.MANUAL,
LaunchViewModel.LaunchCancelReason.AUTOMATIC -> { LaunchViewModel.LaunchCancelReason.AUTOMATIC -> {
Icon( Icon(
@@ -659,10 +666,6 @@ fun AltMainScreen(
) { ) {
val context = LocalContext.current val context = LocalContext.current
LaunchedEffect(Unit) {
launchViewModel.beginLaunchFlow()
}
val launchUIState by launchViewModel.uiState.collectAsState() val launchUIState by launchViewModel.uiState.collectAsState()
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var launchInSafeMode by remember { var launchInSafeMode by remember {
@@ -0,0 +1,70 @@
package com.geode.launcher.log
import androidx.compose.runtime.mutableStateListOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.io.File
abstract class BaseDirectoryViewModel<T>(protected val baseDirectory: File) : ViewModel() {
private val _lineState = mutableStateListOf<T>()
val lineState = _lineState
private val _isLoading = MutableStateFlow(false)
val isLoading = _isLoading.asStateFlow()
private var logJob: Job? = null
abstract fun getFileList(): List<T>
abstract fun getItemKey(item: T): Any
open fun clearAllFiles() {
logJob?.cancel()
viewModelScope.launch(Dispatchers.IO) {
if (baseDirectory.exists()) {
val files = baseDirectory.listFiles()
files?.forEach {
it.delete()
}
}
}
_lineState.clear()
}
private fun loadLogs() {
_isLoading.value = true
logJob?.cancel()
logJob = viewModelScope.launch(Dispatchers.IO) {
val lines = getFileList()
_lineState.clear()
_lineState.addAll(lines)
_isLoading.value = false
logJob = null
}
}
fun removeFile(filename: String) {
logJob?.cancel()
val crashPath = File(baseDirectory, filename)
if (crashPath.exists()) {
crashPath.delete()
loadLogs()
}
}
init {
loadLogs()
}
}
@@ -1,53 +1,40 @@
package com.geode.launcher.log package com.geode.launcher.log
import android.app.Application import android.app.Application
import androidx.compose.runtime.mutableStateListOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import com.geode.launcher.utils.LaunchUtils import com.geode.launcher.utils.LaunchUtils
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File import java.io.File
import kotlin.time.ExperimentalTime
import kotlin.time.Instant import kotlin.time.Instant
data class CrashDump @OptIn(ExperimentalTime::class) constructor( data class CrashDump(
val filename: String, val filename: String,
val lastModified: Instant val lastModified: Instant
) )
class CrashViewModel(private val application: Application) : ViewModel() { class CrashViewModel(crashDirectory: File) : BaseDirectoryViewModel<CrashDump>(crashDirectory) {
companion object { companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory { val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer { initializer {
val application = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as Application val application = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as Application
val crashDirectory = LaunchUtils.getCrashDirectory(application)
CrashViewModel( CrashViewModel(crashDirectory)
application = application
)
} }
} }
} }
private val _lineState = mutableStateListOf<CrashDump>() var hasIndicator = false
val lineState = _lineState
private val _isLoading = MutableStateFlow(false) override fun getItemKey(item: CrashDump) = item.filename
val isLoading = _isLoading.asStateFlow()
private var failedToLoad = false override fun getFileList(): List<CrashDump> {
val crashDirectory = baseDirectory
private var logJob: Job? = null
@OptIn(ExperimentalTime::class)
private fun getCrashList(): List<CrashDump> {
val crashDirectory = LaunchUtils.getCrashDirectory(application)
if (!crashDirectory.exists()) { if (!crashDirectory.exists()) {
return emptyList() return emptyList()
} }
@@ -57,6 +44,8 @@ class CrashViewModel(private val application: Application) : ViewModel() {
_, name -> name != LaunchUtils.CRASH_INDICATOR_NAME && name != "last-pid" _, name -> name != LaunchUtils.CRASH_INDICATOR_NAME && name != "last-pid"
} ?: return emptyList() } ?: return emptyList()
hasIndicator = File(crashDirectory, LaunchUtils.CRASH_INDICATOR_NAME).exists()
return children return children
.map { .map {
CrashDump( CrashDump(
@@ -67,49 +56,24 @@ class CrashViewModel(private val application: Application) : ViewModel() {
.sortedByDescending { it.lastModified } .sortedByDescending { it.lastModified }
} }
fun clearCrashes() { fun clearIndicator() {
logJob?.cancel()
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val crashDirectory = LaunchUtils.getCrashDirectory(application) File(baseDirectory, LaunchUtils.CRASH_INDICATOR_NAME).delete()
if (crashDirectory.exists()) {
val files = crashDirectory.listFiles()
files?.forEach {
it.delete()
}
}
}
_lineState.clear() hasIndicator = false
}
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) { fun createIndicator() {
logJob?.cancel() viewModelScope.launch(Dispatchers.IO) {
File(baseDirectory, LaunchUtils.CRASH_INDICATOR_NAME).createNewFile()
val crashPath = File(LaunchUtils.getCrashDirectory(application), filename) hasIndicator = true
if (crashPath.exists()) {
crashPath.delete()
loadLogs()
} }
} }
init { override fun clearAllFiles() {
loadLogs() super.clearAllFiles()
hasIndicator = false
} }
} }
@@ -0,0 +1,64 @@
package com.geode.launcher.log
import android.app.Application
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.geode.launcher.utils.LaunchUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import kotlin.time.Instant
data class GeodeLog(
val filename: String,
val lastModified: Instant,
val fileSize: Long,
)
class GeodeLogsViewModel(crashDirectory: File) : BaseDirectoryViewModel<GeodeLog>(crashDirectory) {
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as Application
val crashDirectory = LaunchUtils.getGeodeLogsDirectory(application)
GeodeLogsViewModel(crashDirectory)
}
}
}
override fun getItemKey(item: GeodeLog) = item.filename
override fun getFileList(): List<GeodeLog> {
if (!baseDirectory.exists()) {
return emptyList()
}
val children = baseDirectory.listFiles()
?: return emptyList()
return children
.map {
GeodeLog(
filename = it.name,
lastModified = Instant.fromEpochMilliseconds(it.lastModified()),
fileSize = it.length()
)
}
.sortedByDescending { it.lastModified }
}
suspend fun getFileText(filename: String): List<String> {
val logFile = File(baseDirectory, filename)
if (!logFile.exists()) {
return listOf("File does not exist!")
}
return runCatching {
withContext(Dispatchers.IO) {
logFile.readLines()
}
}.getOrDefault(emptyList())
}
}
@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
@@ -38,12 +39,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.TextLinkStyles
@@ -180,33 +179,35 @@ fun ErrorInfoDescription(description: String, modifier: Modifier = Modifier) {
GamePackageUtils.getUnifiedVersionName(context.packageManager) GamePackageUtils.getUnifiedVersionName(context.packageManager)
} }
Column(modifier = modifier) { SelectionContainer {
Text( Column(modifier = modifier) {
stringResource(R.string.launcher_error_details), Text(
style = Typography.headlineSmall stringResource(R.string.launcher_error_details),
) style = Typography.headlineSmall
)
Spacer(Modifier.size(8.dp)) Spacer(Modifier.size(8.dp))
Text( Text(
description, description,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
style = Typography.bodyMedium style = Typography.bodyMedium
) )
Spacer(Modifier.size(4.dp)) Spacer(Modifier.size(4.dp))
Text( Text(
stringResource(R.string.launcher_error_device_info, stringResource(R.string.launcher_error_device_info,
Build.MODEL, Build.MODEL,
Build.VERSION.RELEASE, Build.VERSION.RELEASE,
currentRelease ?: "unknown", currentRelease ?: "unknown",
gameVersion, gameVersion,
LaunchUtils.applicationArchitecture, LaunchUtils.applicationArchitecture,
BuildConfig.VERSION_NAME BuildConfig.VERSION_NAME
), ),
style = Typography.labelMedium style = Typography.labelMedium
) )
}
} }
} }
@@ -10,14 +10,16 @@ import androidx.compose.animation.shrinkHorizontally
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.displayCutoutPadding import androidx.compose.foundation.layout.displayCutoutPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
@@ -27,7 +29,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -43,10 +44,10 @@ import com.geode.launcher.utils.PreferenceUtils
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
enum class LaunchNotificationType { enum class LaunchNotificationType {
GEODE_UPDATED, LAUNCHER_UPDATE_AVAILABLE, UPDATE_FAILED, UNSUPPORTED_VERSION; GEODE_UPDATED, LAUNCHER_UPDATE_AVAILABLE, UPDATE_FAILED, UNSUPPORTED_VERSION, FAILED_LOAD;
} }
fun determineDisplayedCards(context: Context): List<LaunchNotificationType> { fun determineDisplayedCards(context: Context, geodeFailed: Boolean = false): List<LaunchNotificationType> {
val cards = mutableListOf<LaunchNotificationType>() val cards = mutableListOf<LaunchNotificationType>()
if (GamePackageUtils.getGameVersionCode(context.packageManager) < Constants.SUPPORTED_VERSION_CODE_MIN_WARNING) { if (GamePackageUtils.getGameVersionCode(context.packageManager) < Constants.SUPPORTED_VERSION_CODE_MIN_WARNING) {
@@ -54,10 +55,8 @@ fun determineDisplayedCards(context: Context): List<LaunchNotificationType> {
} }
val releaseManager = ReleaseManager.get(context) val releaseManager = ReleaseManager.get(context)
val loadAutomatically = PreferenceUtils.get(context).getBoolean(PreferenceUtils.Key.LOAD_AUTOMATICALLY) val availableUpdate = releaseManager.availableLauncherUpdateTag.value
if (availableUpdate != null && !releaseManager.dismissedLauncherUpdate) {
val availableUpdate = releaseManager.availableLauncherUpdate.value
if (loadAutomatically && availableUpdate != null) {
cards.add(LaunchNotificationType.LAUNCHER_UPDATE_AVAILABLE) cards.add(LaunchNotificationType.LAUNCHER_UPDATE_AVAILABLE)
} }
@@ -71,6 +70,10 @@ fun determineDisplayedCards(context: Context): List<LaunchNotificationType> {
cards.add(LaunchNotificationType.UPDATE_FAILED) cards.add(LaunchNotificationType.UPDATE_FAILED)
} }
if (geodeFailed) {
cards.add(LaunchNotificationType.FAILED_LOAD)
}
return cards return cards
} }
@@ -105,23 +108,25 @@ fun NotificationCardFromType(type: LaunchNotificationType) {
OutdatedVersionContent() OutdatedVersionContent()
} }
} }
LaunchNotificationType.FAILED_LOAD -> {
AnimatedNotificationCard {
ForwardCompatibilityContent()
}
}
else -> {} else -> {}
} }
} }
@Composable @Composable
fun LaunchNotification() { fun LaunchNotification(cards: List<LaunchNotificationType>) {
val context = LocalContext.current
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME) val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
val theme = Theme.fromInt(themeOption) val theme = Theme.fromInt(themeOption)
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND) val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
val cards = determineDisplayedCards(context)
CompositionLocalProvider(LocalTheme provides theme) { CompositionLocalProvider(LocalTheme provides theme) {
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption) { GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
// surface is not in use, so this is unfortunately not provided // surface is not in use, so this is unfortunately not provided
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
Column( Column(
@@ -207,6 +212,7 @@ fun LauncherUpdateContent(modifier: Modifier = Modifier) {
) )
} }
/*
@Composable @Composable
fun UpdateNotificationContent(modifier: Modifier = Modifier) { fun UpdateNotificationContent(modifier: Modifier = Modifier) {
ListItem( ListItem(
@@ -223,6 +229,7 @@ fun UpdateNotificationContent(modifier: Modifier = Modifier) {
modifier = modifier.width(IntrinsicSize.Max) modifier = modifier.width(IntrinsicSize.Max)
) )
} }
*/
@Composable @Composable
fun OutdatedVersionContent(modifier: Modifier = Modifier) { fun OutdatedVersionContent(modifier: Modifier = Modifier) {
@@ -257,4 +264,46 @@ fun UpdateFailedContent(modifier: Modifier = Modifier) {
}, },
modifier = modifier.width(IntrinsicSize.Max) modifier = modifier.width(IntrinsicSize.Max)
) )
}
@Composable
fun ForwardCompatibilityContent(modifier: Modifier = Modifier) {
ListItem(
headlineContent = {
Text(text = stringResource(id = R.string.launcher_notification_forwards_compatibility))
},
leadingContent = {
Icon(
painterResource(R.drawable.icon_warning),
contentDescription = null,
)
},
supportingContent = {
Text(text = stringResource(id = R.string.launcher_notification_forwards_compatibility_description))
},
modifier = modifier.width(IntrinsicSize.Max)
)
}
@Composable
fun FullScreenLoadingIndicator() {
val theme = Theme.DARK
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
CompositionLocalProvider(LocalTheme provides theme) {
GeodeLauncherTheme(theme = theme, blackBackground = true, dynamicColor = !dynamicColorOption) {
Surface(modifier = Modifier.fillMaxSize()) {
var showProgress by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
delay(1000L)
showProgress = true
}
AnimatedVisibility(showProgress) {
CircularProgressIndicator()
}
}
}
}
} }
@@ -65,17 +65,25 @@ class LaunchViewModel(private val application: Application): ViewModel() {
} }
enum class LaunchCancelReason { enum class LaunchCancelReason {
MANUAL, AUTOMATIC, LAST_LAUNCH_CRASHED, GEODE_NOT_FOUND, GAME_MISSING, GAME_OUTDATED; MANUAL, AUTOMATIC, LAST_LAUNCH_CRASHED, LAST_LAUNCH_CRASHED_AUTOMATIC,
GEODE_NOT_FOUND, GAME_MISSING, GAME_OUTDATED;
fun allowsRetry() = when (this) { fun allowsRetry() = when (this) {
MANUAL, MANUAL,
AUTOMATIC, AUTOMATIC,
LAST_LAUNCH_CRASHED, LAST_LAUNCH_CRASHED,
LAST_LAUNCH_CRASHED_AUTOMATIC,
GEODE_NOT_FOUND -> true GEODE_NOT_FOUND -> true
GAME_MISSING, GAME_MISSING,
GAME_OUTDATED -> false GAME_OUTDATED -> false
} }
fun isCrash() = when (this) {
LAST_LAUNCH_CRASHED,
LAST_LAUNCH_CRASHED_AUTOMATIC -> true
else -> false
}
fun isGameInstallIssue() = when (this) { fun isGameInstallIssue() = when (this) {
GAME_OUTDATED, GAME_OUTDATED,
GAME_MISSING -> true GAME_MISSING -> true
@@ -100,23 +108,10 @@ class LaunchViewModel(private val application: Application): ViewModel() {
} }
} }
private val _uiState: MutableStateFlow<LaunchUIState> private val _uiState = MutableStateFlow<LaunchUIState>(LaunchUIState.Initial)
init {
val preferenceUtils = PreferenceUtils.get(application)
val initialCancel = !preferenceUtils.getBoolean(PreferenceUtils.Key.UPDATE_AUTOMATICALLY)
&& !preferenceUtils.getBoolean(PreferenceUtils.Key.LOAD_AUTOMATICALLY)
// fixes a single frame of progress being displayed when we're not actually going to do work
_uiState = MutableStateFlow<LaunchUIState>(
if (initialCancel) LaunchUIState.Cancelled(LaunchCancelReason.AUTOMATIC)
else LaunchUIState.Initial
)
}
val uiState = _uiState.asStateFlow() val uiState = _uiState.asStateFlow()
val nextLauncherUpdate = ReleaseManager.get(application).availableLauncherUpdate val nextLauncherUpdate = ReleaseManager.get(application).availableLauncherUpdateTag
var loadFailure: LoadFailureInfo? = null var loadFailure: LoadFailureInfo? = null
var launchArguments: LaunchArguments? = null var launchArguments: LaunchArguments? = null
@@ -153,7 +148,7 @@ class LaunchViewModel(private val application: Application): ViewModel() {
fun currentCrashInfo(): LoadFailureInfo? { fun currentCrashInfo(): LoadFailureInfo? {
val currentState = _uiState.value val currentState = _uiState.value
if (currentState is LaunchUIState.Cancelled && currentState.reason == LaunchCancelReason.LAST_LAUNCH_CRASHED) { if (currentState is LaunchUIState.Cancelled && currentState.reason.isCrash()) {
return loadFailure return loadFailure
} }
@@ -162,12 +157,12 @@ class LaunchViewModel(private val application: Application): ViewModel() {
fun clearCrashInfo() { fun clearCrashInfo() {
val currentState = _uiState.value val currentState = _uiState.value
if (currentState is LaunchUIState.Cancelled && currentState.reason == LaunchCancelReason.LAST_LAUNCH_CRASHED) { if (currentState is LaunchUIState.Cancelled && currentState.reason.isCrash()) {
loadFailure = null loadFailure = null
} }
} }
private suspend fun preReadyCheck() { private fun preReadyCheck() {
if (isCancelling) { if (isCancelling) {
// don't let the game start if cancelled // don't let the game start if cancelled
return return
@@ -176,7 +171,8 @@ class LaunchViewModel(private val application: Application): ViewModel() {
val packageManager = application.packageManager val packageManager = application.packageManager
if (!GamePackageUtils.isGameInstalled(packageManager)) { if (!GamePackageUtils.isGameInstalled(packageManager)) {
return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_MISSING)) _uiState.tryEmit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_MISSING))
return
} }
if (ReleaseManager.get(application).isInUpdate) { if (ReleaseManager.get(application).isInUpdate) {
@@ -186,17 +182,25 @@ class LaunchViewModel(private val application: Application): ViewModel() {
return return
} }
// last launch crashed, so cancel
if (loadFailure != null) { if (loadFailure != null) {
// last launch crashed, so cancel // show a different message if this is the first thing users see
return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.LAST_LAUNCH_CRASHED)) val cancelType = if (_uiState.value == LaunchUIState.Initial) {
LaunchCancelReason.LAST_LAUNCH_CRASHED_AUTOMATIC
} else LaunchCancelReason.LAST_LAUNCH_CRASHED
_uiState.tryEmit(LaunchUIState.Cancelled(cancelType))
return
} }
if (GamePackageUtils.getGameVersionCode(packageManager) < Constants.SUPPORTED_VERSION_CODE_MIN) { if (GamePackageUtils.getGameVersionCode(packageManager) < Constants.SUPPORTED_VERSION_CODE_MIN) {
return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_OUTDATED)) _uiState.tryEmit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_OUTDATED))
return
} }
if (!LaunchUtils.isGeodeInstalled(application)) { if (!LaunchUtils.isGeodeInstalled(application)) {
return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GEODE_NOT_FOUND)) _uiState.tryEmit(LaunchUIState.Cancelled(LaunchCancelReason.GEODE_NOT_FOUND))
return
} }
// if forcing immediate launch, then act as if it's manually started (skips timers) // if forcing immediate launch, then act as if it's manually started (skips timers)
@@ -212,13 +216,14 @@ class LaunchViewModel(private val application: Application): ViewModel() {
} }
if (!readyTimerPassed && !hasManuallyStarted) { if (!readyTimerPassed && !hasManuallyStarted) {
return _uiState.emit(LaunchUIState.Working) _uiState.tryEmit(LaunchUIState.Working)
return
} }
_uiState.emit(LaunchUIState.Ready) _uiState.tryEmit(LaunchUIState.Ready)
} }
suspend fun beginLaunchFlow(isRestart: Boolean = false) { fun beginLaunchFlow(isRestart: Boolean = false) {
if (isRestart) { if (isRestart) {
hasManuallyStarted = true hasManuallyStarted = true
} }
@@ -234,17 +239,27 @@ class LaunchViewModel(private val application: Application): ViewModel() {
isCancelling = false isCancelling = false
if (!GamePackageUtils.isGameInstalled(application.packageManager)) { if (!GamePackageUtils.isGameInstalled(application.packageManager)) {
return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_MISSING)) _uiState.tryEmit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_MISSING))
return
} }
if (readyTimer == null) { if (readyTimer == null) {
initReadyTimer() initReadyTimer()
} }
val releaseManager = ReleaseManager.get(application)
val hasGeode = LaunchUtils.isGeodeInstalled(application) val hasGeode = LaunchUtils.isGeodeInstalled(application)
val shouldUpdate = PreferenceUtils.get(application).getBoolean(PreferenceUtils.Key.UPDATE_AUTOMATICALLY) val shouldUpdate = PreferenceUtils.get(application).getBoolean(PreferenceUtils.Key.UPDATE_AUTOMATICALLY)
if ((shouldUpdate && !hasCheckedForUpdates) || !hasGeode) { if ((shouldUpdate && !hasCheckedForUpdates) || !hasGeode) {
ReleaseManager.get(application).checkForUpdates(false) releaseManager.checkForUpdates(false)
} else {
releaseManager.afterUseCachedUpdate()
}
if (isRestart) {
releaseManager.dismissedLauncherUpdate = true
} }
preReadyCheck() preReadyCheck()
@@ -261,7 +276,7 @@ class LaunchViewModel(private val application: Application): ViewModel() {
} }
} }
suspend fun cancelLaunch(isAutomatic: Boolean = false) { fun cancelLaunch(isAutomatic: Boolean = false) {
// no need to double cancel // no need to double cancel
if (_uiState.value is LaunchUIState.Cancelled) { if (_uiState.value is LaunchUIState.Cancelled) {
return return
@@ -273,11 +288,14 @@ class LaunchViewModel(private val application: Application): ViewModel() {
val releaseManager = ReleaseManager.get(application) val releaseManager = ReleaseManager.get(application)
if (releaseManager.isInUpdate) { if (releaseManager.isInUpdate) {
_uiState.emit(LaunchUIState.Cancelled(reason, true)) _uiState.tryEmit(LaunchUIState.Cancelled(reason, true))
releaseManager.cancelUpdate()
viewModelScope.launch {
releaseManager.cancelUpdate()
}
} }
_uiState.emit(LaunchUIState.Cancelled(reason, false)) _uiState.tryEmit(LaunchUIState.Cancelled(reason, false))
} }
override fun onCleared() { override fun onCleared() {
@@ -11,15 +11,14 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -29,7 +28,6 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
@@ -51,10 +49,9 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.geode.launcher.InstallReceiver import com.geode.launcher.InstallReceiver
import com.geode.launcher.R import com.geode.launcher.R
import com.geode.launcher.main.LauncherUpdater.downloadUpdate
import com.geode.launcher.ui.theme.Typography import com.geode.launcher.ui.theme.Typography
import com.geode.launcher.updater.ReleaseManager import com.geode.launcher.updater.ReleaseManager
import com.geode.launcher.utils.LaunchUtils import com.geode.launcher.utils.LaunchUtils
@@ -63,9 +60,12 @@ import com.mikepenz.markdown.compose.LocalBulletListHandler
import com.mikepenz.markdown.m3.Markdown import com.mikepenz.markdown.m3.Markdown
import com.mikepenz.markdown.m3.markdownTypography import com.mikepenz.markdown.m3.markdownTypography
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
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.runInterruptible
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor
import java.io.File import java.io.File
@@ -101,6 +101,7 @@ fun clearDownloadedApks(context: Context) {
object LauncherUpdater { object LauncherUpdater {
enum class LauncherUpdateState { enum class LauncherUpdateState {
Began, Began,
Downloading,
Active, Active,
Inactive, Inactive,
Finished; Finished;
@@ -113,12 +114,16 @@ object LauncherUpdater {
_uiState.emit(LauncherUpdateState.Inactive) _uiState.emit(LauncherUpdateState.Inactive)
} }
suspend fun installPackage(context: Context, file: File) { suspend fun downloadUpdate(context: Context): Result<File> {
if (!file.exists()) { _uiState.emit(LauncherUpdateState.Downloading)
_uiState.emit(LauncherUpdateState.Finished)
return
}
return withContext(Dispatchers.IO) {
ReleaseManager.get(context)
.downloadLatestLauncherUpdate()
}
}
suspend fun installPackage(context: Context, file: File) {
_uiState.emit(LauncherUpdateState.Began) _uiState.emit(LauncherUpdateState.Began)
val installer = context.packageManager.packageInstaller val installer = context.packageManager.packageInstaller
@@ -147,7 +152,7 @@ object LauncherUpdater {
} }
}) })
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) { runInterruptible {
file.inputStream().use { apkStream -> file.inputStream().use { apkStream ->
val length = file.length() val length = file.length()
@@ -155,105 +160,94 @@ object LauncherUpdater {
PackageInstaller.SessionParams.MODE_FULL_INSTALL, PackageInstaller.SessionParams.MODE_FULL_INSTALL,
) )
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
}
val sessionId = installer.createSession(params) val sessionId = installer.createSession(params)
val session = installer.openSession(sessionId) installer.openSession(sessionId).use { session ->
session.openWrite("package", 0, length).use { sessionStream ->
apkStream.copyTo(sessionStream)
session.fsync(sessionStream)
}
session.openWrite("package", 0, length).use { sessionStream -> val intent = Intent(context, InstallReceiver::class.java)
apkStream.copyTo(sessionStream) val pi = PendingIntent.getBroadcast(
session.fsync(sessionStream) context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
)
session.commit(pi.intentSender)
} }
val intent = Intent(context, InstallReceiver::class.java)
val pi = PendingIntent.getBroadcast(
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
)
session.commit(pi.intentSender)
session.close()
} }
} }}
} }
} }
suspend fun installLauncherUpdate(context: Context) { suspend fun installLauncherUpdate(context: Context) {
val nextUpdate = ReleaseManager.get(context).availableLauncherUpdate.value val launcherDownload = downloadUpdate(context)
val launcherDownload = nextUpdate?.getDownload() .getOrElse { e ->
e.printStackTrace()
if (launcherDownload != null) { Toast.makeText(
val outputFile = launcherDownload.filename context, context.getString(
val baseDirectory = context.filesDir R.string.launcher_update_download_failed,
e.cause
val outputPathFile = File(baseDirectory, outputFile) ), Toast.LENGTH_SHORT
if (!outputPathFile.exists()) { ).show()
return return
} }
PreferenceUtils.get(context).setBoolean(PreferenceUtils.Key.CLEANUP_APKS, true) if (!launcherDownload.exists()) {
return
}
try { PreferenceUtils.get(context).setBoolean(PreferenceUtils.Key.CLEANUP_APKS, true)
LauncherUpdater.installPackage(context, outputPathFile)
} catch (e: Exception) {
e.printStackTrace()
val msg = context.getString(R.string.launcher_self_update_failed, e.message)
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
LauncherUpdater.resetState() try {
} LauncherUpdater.installPackage(context, launcherDownload)
} else { } catch (e: Exception) {
Toast.makeText(context, context.getString(R.string.release_fetch_no_releases), Toast.LENGTH_SHORT).show() e.printStackTrace()
val msg = context.getString(R.string.launcher_self_update_failed, e.message)
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
LauncherUpdater.resetState()
} }
} }
@Composable @Composable
fun LauncherUpdateDialog(onDismiss: () -> Unit) { fun LauncherUpdateDialog(isCancelling: Boolean, onDismiss: () -> Unit) {
val updateState by LauncherUpdater.uiState.collectAsState() val updateState by LauncherUpdater.uiState.collectAsState()
val cancellable = updateState != LauncherUpdater.LauncherUpdateState.Active
LaunchedEffect(updateState) { LaunchedEffect(updateState) {
if (updateState == LauncherUpdater.LauncherUpdateState.Finished) { if (updateState == LauncherUpdater.LauncherUpdateState.Finished) {
onDismiss() onDismiss()
} }
} }
if (cancellable) { AlertDialog(
return icon = {
} Icon(
painterResource(R.drawable.icon_update),
Dialog( contentDescription = null
onDismissRequest = { )
if (cancellable) onDismiss()
}, },
properties = DialogProperties(usePlatformDefaultWidth = false) title = {
) { if (isCancelling) {
Card( Text(stringResource(R.string.release_fetch_cancelling))
shape = RoundedCornerShape(16.dp), } else if (updateState == LauncherUpdater.LauncherUpdateState.Downloading) {
colors = CardDefaults.cardColors( Text(stringResource(R.string.launcher_downloading_update))
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh } else {
), Text(stringResource(R.string.launcher_self_update_progress))
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(18.dp),
modifier = Modifier.padding(24.dp)
) {
CircularProgressIndicator()
Text(
stringResource(R.string.launcher_self_update_progress),
style = MaterialTheme.typography.bodyLarge
)
} }
} },
} text = {
LinearProgressIndicator()
},
confirmButton = {
TextButton(onClick = onDismiss, enabled = !isCancelling) {
Text(stringResource(R.string.message_box_cancel))
}
},
onDismissRequest = {}
)
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -261,84 +255,121 @@ fun LauncherUpdateDialog(onDismiss: () -> Unit) {
fun LauncherUpdateInformation(onDismiss: () -> Unit) { fun LauncherUpdateInformation(onDismiss: () -> Unit) {
val context = LocalContext.current val context = LocalContext.current
val nextRelease = remember { ReleaseManager.get(context).availableLauncherUpdate.value } val releaseManager = remember { ReleaseManager.get(context) }
val nextReleaseName by releaseManager.availableLauncherUpdateTag.collectAsState()
val nextRelease by releaseManager.availableLauncherUpdateDetails.collectAsState()
val sheetState = rememberModalBottomSheetState() val sheetState = rememberModalBottomSheetState()
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var updateJob by remember { mutableStateOf<Job?>(null) }
var cancellingUpdate by remember { mutableStateOf(false) }
var beginUpdate by remember { mutableStateOf(false) } var beginUpdate by remember { mutableStateOf(false) }
if (beginUpdate) { if (beginUpdate) {
LauncherUpdateDialog(onDismiss = { LauncherUpdateDialog(cancellingUpdate, onDismiss = {
beginUpdate = false cancellingUpdate = true
coroutineScope.launch {
updateJob?.cancelAndJoin()
beginUpdate = false
}
}) })
} }
if (nextRelease != null) { LaunchedEffect(Unit) {
if (nextReleaseName != null && nextRelease == null) {
coroutineScope.launch { withContext(Dispatchers.IO) {
ReleaseManager.get(context)
.checkLauncherUpdate()
}}
}
}
val nextReleaseNameSaved = nextReleaseName
if (nextReleaseNameSaved != null) {
ModalBottomSheet(onDismissRequest = { onDismiss() }, sheetState = sheetState) { ModalBottomSheet(onDismissRequest = { onDismiss() }, sheetState = sheetState) {
Box(Modifier.verticalScroll(rememberScrollState())) { Box(Modifier.verticalScroll(rememberScrollState())) {
Column(modifier = Modifier.padding(horizontal = 24.dp)) { Column(modifier = Modifier.padding(horizontal = 24.dp)) {
Text( Text(
stringResource(R.string.launcher_update_name, nextRelease.release.tagName), stringResource(R.string.launcher_update_name, nextReleaseNameSaved),
style = Typography.headlineSmall, style = Typography.headlineSmall,
) )
val releasedTime = remember { val nextRelease = nextRelease
DateFormat.getDateInstance().format( if (nextRelease != null) {
Date.from(nextRelease.release.publishedAt.toJavaInstant()) 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 = {
coroutineScope.launch {
installLauncherUpdate(context)
beginUpdate = true
}
},
) {
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( Text(
stringResource(R.string.launcher_update_changelog), stringResource(R.string.launcher_update_released, "$releasedTime"),
style = Typography.titleLarge style = Typography.labelLarge
) )
// every new release of the markdown library adds another arg to it Row(modifier = Modifier.padding(vertical = 12.dp)) {
CompositionLocalProvider(LocalBulletListHandler provides { _, _, _, _, _ -> "•  " }) { Button(
Markdown( onClick = {
content = nextRelease.release.body.replace("\r", ""), updateJob = coroutineScope.launch {
typography = markdownTypography( cancellingUpdate = false
textLink = TextLinkStyles( beginUpdate = true
style = MaterialTheme.typography.bodyLarge.copy(
textDecoration = TextDecoration.Underline, installLauncherUpdate(context)
color = MaterialTheme.colorScheme.primary, }
).toSpanStyle() },
), ) {
), Icon(painterResource(R.drawable.icon_download), contentDescription = null)
flavour = GFMFlavourDescriptor(), Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_install))
}
val uriHandler = LocalUriHandler.current
IconButton(onClick = { uriHandler.openUri(nextRelease.release.htmlUrl) }) {
Icon(
painterResource(R.drawable.icon_link),
stringResource(R.string.launcher_update_external_link)
)
}
}
if (nextRelease.release.body != null) {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text(
stringResource(R.string.launcher_update_changelog),
style = Typography.titleLarge
) )
// every new release of the markdown library adds another arg to it
CompositionLocalProvider(LocalBulletListHandler provides { _, _, _, _, _ -> "•  " }) {
Markdown(
content = nextRelease.release.body.replace("\r", ""),
typography = markdownTypography(
textLink = TextLinkStyles(
style = MaterialTheme.typography.bodyLarge.copy(
textDecoration = TextDecoration.Underline,
color = MaterialTheme.colorScheme.primary,
).toSpanStyle()
),
),
flavour = GFMFlavourDescriptor(),
)
}
}
} else {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp)
) {
CircularProgressIndicator()
} }
} }
@@ -362,10 +393,19 @@ fun LauncherUpdateIndicator(modifier: Modifier = Modifier) {
} }
} }
var updateJob by remember { mutableStateOf<Job?>(null) }
var cancellingUpdate by remember { mutableStateOf(false) }
var beginUpdate by remember { mutableStateOf(false) } var beginUpdate by remember { mutableStateOf(false) }
if (beginUpdate) { if (beginUpdate) {
LauncherUpdateDialog { LauncherUpdateDialog(cancellingUpdate) {
beginUpdate = false cancellingUpdate = true
coroutineScope.launch {
updateJob?.cancelAndJoin()
updateJob = null
beginUpdate = false
}
} }
} }
@@ -394,9 +434,11 @@ fun LauncherUpdateIndicator(modifier: Modifier = Modifier) {
TextButton( TextButton(
onClick = { onClick = {
coroutineScope.launch { updateJob = coroutineScope.launch {
installLauncherUpdate(context) cancellingUpdate = false
beginUpdate = true beginUpdate = true
installLauncherUpdate(context)
} }
}, },
) { ) {
@@ -1,6 +1,5 @@
package com.geode.launcher.preferences package com.geode.launcher.preferences
import android.content.ClipData
import android.os.Bundle import android.os.Bundle
import android.widget.Toast import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
@@ -9,7 +8,6 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -18,6 +16,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.selection.SelectionContainer
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
@@ -42,13 +41,8 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.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
@@ -61,6 +55,8 @@ import com.geode.launcher.R
import com.geode.launcher.log.LogLine import com.geode.launcher.log.LogLine
import com.geode.launcher.log.LogPriority import com.geode.launcher.log.LogPriority
import com.geode.launcher.log.LogViewModel import com.geode.launcher.log.LogViewModel
import com.geode.launcher.preferences.components.SelectDialog
import com.geode.launcher.preferences.components.SelectOption
import com.geode.launcher.ui.theme.GeodeLauncherTheme import com.geode.launcher.ui.theme.GeodeLauncherTheme
import com.geode.launcher.ui.theme.LocalTheme import com.geode.launcher.ui.theme.LocalTheme
import com.geode.launcher.ui.theme.Theme import com.geode.launcher.ui.theme.Theme
@@ -144,46 +140,24 @@ fun SelectLogLevelDialog(
@Composable @Composable
fun LogCard(logLine: LogLine, modifier: Modifier = Modifier) { fun LogCard(logLine: LogLine, modifier: Modifier = Modifier) {
val haptics = LocalHapticFeedback.current SelectionContainer {
val clipboard = LocalClipboard.current Column(
val resources = LocalResources.current modifier = modifier
.padding(8.dp)
) {
Text(
"[${logLine.priority.toChar()}] ${logLine.formattedTime}",
style = Typography.labelSmall,
color = getLogColor(logLine.priority),
fontFamily = robotoMonoFamily
)
val coroutineScope = rememberCoroutineScope() Text(
"[${logLine.tag}]: ${logLine.messageTrimmed}",
Column( fontFamily = robotoMonoFamily,
modifier = modifier style = Typography.bodyMedium
.padding(8.dp) )
.pointerInput(Unit) { }
detectTapGestures(
onLongPress = {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
val clipboardLabel = resources.getString(R.string.application_log_copy_label)
val clipboardData = logLine.asSimpleString
coroutineScope.launch {
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText(
clipboardLabel, clipboardData
))
)
}
}
)
},
) {
Text(
"[${logLine.priority.toChar()}] ${logLine.formattedTime}",
style = Typography.labelSmall,
color = getLogColor(logLine.priority),
fontFamily = robotoMonoFamily
)
Text(
"[${logLine.tag}]: ${logLine.messageTrimmed}",
fontFamily = robotoMonoFamily,
style = Typography.bodyMedium
)
} }
} }
@@ -15,43 +15,28 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding 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.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.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.draw.clip
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -60,16 +45,15 @@ import com.geode.launcher.R
import com.geode.launcher.UserDirectoryProvider import com.geode.launcher.UserDirectoryProvider
import com.geode.launcher.log.CrashDump import com.geode.launcher.log.CrashDump
import com.geode.launcher.log.CrashViewModel import com.geode.launcher.log.CrashViewModel
import com.geode.launcher.preferences.components.DirectoryListingScreen
import com.geode.launcher.ui.theme.GeodeLauncherTheme import com.geode.launcher.ui.theme.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.robotoMonoFamily import com.geode.launcher.ui.theme.robotoMonoFamily
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 java.io.File import java.io.File
import java.util.Date import java.util.Date
import kotlin.time.ExperimentalTime
import kotlin.time.toJavaInstant import kotlin.time.toJavaInstant
class CrashLogsActivity : ComponentActivity() { class CrashLogsActivity : ComponentActivity() {
@@ -124,7 +108,6 @@ fun shareCrash(context: Context, filename: String) {
} }
} }
@OptIn(ExperimentalTime::class)
@Composable @Composable
fun CrashCard(crashDump: CrashDump, crashViewModel: CrashViewModel, modifier: Modifier = Modifier) { fun CrashCard(crashDump: CrashDump, crashViewModel: CrashViewModel, modifier: Modifier = Modifier) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier
@@ -169,7 +152,7 @@ fun CrashCard(crashDump: CrashDump, crashViewModel: CrashViewModel, modifier: Mo
} }
IconButton(onClick = { IconButton(onClick = {
crashViewModel.removeCrash(crashDump.filename) crashViewModel.removeFile(crashDump.filename)
}) { }) {
Icon( Icon(
painterResource(R.drawable.icon_delete), painterResource(R.drawable.icon_delete),
@@ -180,119 +163,58 @@ fun CrashCard(crashDump: CrashDump, crashViewModel: CrashViewModel, modifier: Mo
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun CrashLogsScreen( fun CrashLogsScreen(
onBackPressedDispatcher: OnBackPressedDispatcher?, onBackPressedDispatcher: OnBackPressedDispatcher?,
crashViewModel: CrashViewModel = viewModel(factory = CrashViewModel.Factory) crashViewModel: CrashViewModel = viewModel(factory = CrashViewModel.Factory)
) { ) {
val logLines = crashViewModel.lineState DirectoryListingScreen(
val isLoading by crashViewModel.isLoading.collectAsState() onBackPressedDispatcher,
crashViewModel,
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() titleId = R.string.title_activity_crash_logs,
noneAvailableId = R.string.application_crashes_no_dumps,
var showMoreOptions by remember { mutableStateOf(false) } additionalOptions = { onDismiss ->
val developerMode by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DEVELOPER_MODE)
Scaffold( if (developerMode) {
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), if (crashViewModel.hasIndicator) {
topBar = { DropdownMenuItem(
Column { leadingIcon = {
TopAppBar(
navigationIcon = {
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
Icon( Icon(
painterResource(R.drawable.icon_arrow_back), painterResource(R.drawable.icon_remove_selection),
contentDescription = stringResource(R.string.back_icon_alt) contentDescription = null
) )
},
text = {
Text(stringResource(R.string.application_crashes_clear_indicator))
}, onClick = {
crashViewModel.clearIndicator()
onDismiss()
} }
}, )
actions = { } else {
IconButton(onClick = { showMoreOptions = !showMoreOptions }) { DropdownMenuItem(
leadingIcon = {
Icon( Icon(
painterResource(R.drawable.icon_more_vert), painterResource(R.drawable.icon_skull),
contentDescription = stringResource(R.string.application_logs_more) contentDescription = null
) )
},
text = {
Text(stringResource(R.string.application_crashes_create_indicator))
}, onClick = {
crashViewModel.createIndicator()
onDismiss()
} }
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 -> ) { line, shape ->
LazyColumn( CrashCard(
Modifier line,
.padding(innerPadding) crashViewModel,
.fillMaxWidth(), modifier = Modifier.clip(shape)
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))
}
}
} }
} }
@@ -302,4 +224,4 @@ fun CrashLogsPreview() {
GeodeLauncherTheme { GeodeLauncherTheme {
CrashLogsScreen(null) CrashLogsScreen(null)
} }
} }
@@ -41,6 +41,13 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.geode.launcher.R import com.geode.launcher.R
import com.geode.launcher.preferences.components.OptionsButton
import com.geode.launcher.preferences.components.OptionsGroup
import com.geode.launcher.preferences.components.ProfileCreateCard
import com.geode.launcher.preferences.components.ProfileSelectCard
import com.geode.launcher.preferences.components.SettingsCard
import com.geode.launcher.preferences.components.SettingsSelectCard
import com.geode.launcher.preferences.components.SettingsStringCard
import com.geode.launcher.ui.theme.GeodeLauncherTheme import com.geode.launcher.ui.theme.GeodeLauncherTheme
import com.geode.launcher.ui.theme.LocalTheme import com.geode.launcher.ui.theme.LocalTheme
import com.geode.launcher.ui.theme.Theme import com.geode.launcher.ui.theme.Theme
@@ -151,10 +158,14 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
title = stringResource(R.string.preference_launch_arguments_name), title = stringResource(R.string.preference_launch_arguments_name),
dialogTitle = stringResource(R.string.preference_launch_arguments_set_title), dialogTitle = stringResource(R.string.preference_launch_arguments_set_title),
preferenceKey = PreferenceUtils.Key.LAUNCH_ARGUMENTS, preferenceKey = PreferenceUtils.Key.LAUNCH_ARGUMENTS,
filterInput = { it.filter { c -> filterInput = {
// if only there was a better way to define this! it.filter { c ->
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890!@#$%^&*(){}<>[]?:;'\"~`-_+=\\| ".contains(c) // if only there was a better way to define this!
}} "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890!@#$%^&*(){}<>[]?:;'\"~`-_+=\\| ".contains(
c
)
}
}
) )
/* /*
@@ -179,13 +190,27 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
title = stringResource(R.string.preferences_view_logs), title = stringResource(R.string.preferences_view_logs),
icon = { icon = {
Icon( Icon(
painterResource(R.drawable.icon_description), painterResource(R.drawable.icon_mobile_code),
contentDescription = null contentDescription = null
) )
}, },
onClick = { onOpenLogs(context) } onClick = { onOpenLogs(context) }
) )
OptionsButton(
title = stringResource(R.string.preferences_view_geode_logs),
icon = {
Icon(
painterResource(R.drawable.icon_description),
contentDescription = null
)
},
onClick = {
val launchIntent = Intent(context, GeodeLogsListingActivity::class.java)
context.startActivity(launchIntent)
}
)
OptionsButton( OptionsButton(
title = stringResource(R.string.preferences_copy_external_button), title = stringResource(R.string.preferences_copy_external_button),
description = LaunchUtils.getBaseDirectory(context).path, description = LaunchUtils.getBaseDirectory(context).path,
@@ -194,6 +219,8 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
} }
OptionsGroup(title = stringResource(R.string.preference_category_updater)) { OptionsGroup(title = stringResource(R.string.preference_category_updater)) {
val context = LocalContext.current
SettingsSelectCard( SettingsSelectCard(
title = stringResource(R.string.preference_release_channel_tag_name), title = stringResource(R.string.preference_release_channel_tag_name),
dialogTitle = stringResource(R.string.preference_release_channel_select), dialogTitle = stringResource(R.string.preference_release_channel_select),
@@ -202,7 +229,16 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
0 to stringResource(R.string.preference_release_channel_stable), 0 to stringResource(R.string.preference_release_channel_stable),
1 to stringResource(R.string.preference_release_channel_beta), 1 to stringResource(R.string.preference_release_channel_beta),
2 to stringResource(R.string.preference_release_channel_nightly), 2 to stringResource(R.string.preference_release_channel_nightly),
) ),
extraSelectBehavior = {
PreferenceUtils.get(context)
.setLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME, 0L)
}
)
SettingsCard(
title = stringResource(R.string.preference_disable_update_cache),
preferenceKey = PreferenceUtils.Key.DISABLE_UPDATE_CACHE
) )
} }
@@ -213,9 +249,21 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
preferenceKey = PreferenceUtils.Key.WAIT_PERIOD, preferenceKey = PreferenceUtils.Key.WAIT_PERIOD,
options = linkedMapOf( options = linkedMapOf(
0 to stringResource(R.string.preference_launch_delay_instant), 0 to stringResource(R.string.preference_launch_delay_instant),
1 to pluralStringResource(R.plurals.preference_launch_delay_value, 1, 1), 1 to pluralStringResource(
3 to pluralStringResource(R.plurals.preference_launch_delay_value, 3, 3), R.plurals.preference_launch_delay_value,
5 to pluralStringResource(R.plurals.preference_launch_delay_value, 5, 5), 1,
1
),
3 to pluralStringResource(
R.plurals.preference_launch_delay_value,
3,
3
),
5 to pluralStringResource(
R.plurals.preference_launch_delay_value,
5,
5
),
) )
) )
@@ -0,0 +1,173 @@
package com.geode.launcher.preferences
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedDispatcher
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.geode.launcher.R
import com.geode.launcher.log.GeodeLogsViewModel
import com.geode.launcher.ui.theme.GeodeLauncherTheme
import com.geode.launcher.ui.theme.LocalTheme
import com.geode.launcher.ui.theme.Theme
import com.geode.launcher.ui.theme.Typography
import com.geode.launcher.ui.theme.robotoMonoFamily
import com.geode.launcher.utils.PreferenceUtils
class GeodeLogViewActivity : ComponentActivity() {
companion object {
const val EXTRA_LOG_VIEW_FILENAME = "EXTRA_LOG_FILENAME"
}
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
val filename = intent.getStringExtra(EXTRA_LOG_VIEW_FILENAME)
if (filename.isNullOrEmpty()) {
val launchIntent = Intent(this, GeodeLogsListingActivity::class.java)
startActivity(launchIntent)
return
}
setContent {
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
val theme = Theme.fromInt(themeOption)
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
CompositionLocalProvider(LocalTheme provides theme) {
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
GeodeLogView(onBackPressedDispatcher, filename)
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GeodeLogView(
onBackPressedDispatcher: OnBackPressedDispatcher?,
filename: String,
geodeLogsViewModel: GeodeLogsViewModel = viewModel(factory = GeodeLogsViewModel.Factory)
) {
var loadingFile by remember { mutableStateOf(true) }
var description by remember { mutableStateOf(emptyList<String>()) }
LaunchedEffect(filename) {
loadingFile = true
description = geodeLogsViewModel.getFileText(filename)
loadingFile = false
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
Column {
TopAppBar(
navigationIcon = {
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
Icon(
painterResource(R.drawable.icon_arrow_back),
contentDescription = stringResource(R.string.back_icon_alt)
)
}
},
title = {
Text(
filename,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
scrollBehavior = scrollBehavior,
)
if (loadingFile) {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth()
)
}
}
}
) { innerPadding ->
if (!loadingFile) {
if (description.isEmpty()) {
Text(
stringResource(R.string.application_geode_logs_empty),
textAlign = TextAlign.Center,
style = Typography.bodyLarge,
modifier = Modifier.fillMaxSize().padding(8.dp)
)
} else {
SelectionContainer {
LazyColumn(modifier = Modifier
.padding(innerPadding)
.padding(horizontal = 8.dp)
) {
items(description) { line ->
Text(
line,
fontFamily = robotoMonoFamily,
style = Typography.bodyMedium
)
}
item {
Spacer(Modifier.height(16.dp))
}
}
}
}
}
}
}
@@ -0,0 +1,187 @@
package com.geode.launcher.preferences
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.provider.DocumentsContract
import android.text.format.Formatter
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedDispatcher
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.geode.launcher.R
import com.geode.launcher.UserDirectoryProvider
import com.geode.launcher.log.GeodeLog
import com.geode.launcher.log.GeodeLogsViewModel
import com.geode.launcher.preferences.components.DirectoryListingScreen
import com.geode.launcher.ui.theme.GeodeLauncherTheme
import com.geode.launcher.ui.theme.LocalTheme
import com.geode.launcher.ui.theme.Theme
import com.geode.launcher.ui.theme.robotoMonoFamily
import com.geode.launcher.utils.LaunchUtils
import com.geode.launcher.utils.PreferenceUtils
import java.io.File
class GeodeLogsListingActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
val theme = Theme.fromInt(themeOption)
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
CompositionLocalProvider(LocalTheme provides theme) {
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
GeodeLogsListingScreen(onBackPressedDispatcher)
}
}
}
}
}
}
fun shareLog(context: Context, filename: String) {
val baseDirectory = LaunchUtils.getBaseDirectory(context)
val crashPath = File(LaunchUtils.getGeodeLogsDirectory(context), filename)
if (!crashPath.exists()) {
Toast.makeText(context, R.string.launcher_error_export_missing, Toast.LENGTH_SHORT).show()
return
}
val providerLocation = crashPath.toRelativeString(baseDirectory)
val documentsPath = "${UserDirectoryProvider.ROOT}${providerLocation}"
val uri = DocumentsContract.buildDocumentUri("${context.packageName}.user", documentsPath)
val shareIntent = Intent(Intent.ACTION_SEND).apply {
putExtra(Intent.EXTRA_STREAM, uri)
type = "text/plain"
}
try {
context.startActivity(shareIntent)
} catch (_: ActivityNotFoundException) {
Toast.makeText(context, R.string.no_activity_found, Toast.LENGTH_SHORT).show()
}
}
@Composable
fun GeodeLogCard(geodeLog: GeodeLog, geodeLogsViewModel: GeodeLogsViewModel, modifier: Modifier = Modifier) {
val context = LocalContext.current
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 1.dp, horizontal = 8.dp)
.height(64.dp)
.then(modifier)
.clickable {
val launchIntent = Intent(context, GeodeLogViewActivity::class.java).run {
putExtra(GeodeLogViewActivity.EXTRA_LOG_VIEW_FILENAME, geodeLog.filename)
}
context.startActivity(launchIntent)
}
.background(MaterialTheme.colorScheme.surfaceContainerLow)
.padding(vertical = 8.dp, horizontal = 8.dp)
) {
Column(
verticalArrangement = Arrangement.SpaceAround,
modifier = Modifier.fillMaxHeight().offset(x = 12.dp).weight(0.7f)
) {
Text(
geodeLog.filename,
fontFamily = robotoMonoFamily,
style = MaterialTheme.typography.titleSmall,
overflow = TextOverflow.Ellipsis,
maxLines = 1
)
val context = LocalContext.current
val formattedSize = remember(geodeLog.fileSize) {
Formatter.formatShortFileSize(context, geodeLog.fileSize)
}
Text(formattedSize, style = MaterialTheme.typography.labelMedium)
}
Row(modifier = Modifier.weight(0.3f), horizontalArrangement = Arrangement.End) {
val context = LocalContext.current
IconButton(onClick = {
shareLog(context, geodeLog.filename)
}) {
Icon(
painterResource(R.drawable.icon_share),
contentDescription = stringResource(R.string.application_logs_share)
)
}
IconButton(onClick = {
geodeLogsViewModel.removeFile(geodeLog.filename)
}) {
Icon(
painterResource(R.drawable.icon_delete),
contentDescription = stringResource(R.string.application_crashes_delete_one)
)
}
}
}
}
@Composable
fun GeodeLogsListingScreen(
onBackPressedDispatcher: OnBackPressedDispatcher?,
geodeLogsViewModel: GeodeLogsViewModel = viewModel(factory = GeodeLogsViewModel.Factory)
) {
DirectoryListingScreen(
onBackPressedDispatcher,
geodeLogsViewModel,
titleId = R.string.title_activity_geode_logs,
noneAvailableId = R.string.application_geode_logs_no_logs,
) { line, shape ->
GeodeLogCard(
line,
geodeLogsViewModel,
modifier = Modifier.clip(shape)
)
}
}
@@ -2,6 +2,7 @@ package com.geode.launcher.preferences
import android.app.UiModeManager import android.app.UiModeManager
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.ClipData
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
@@ -19,10 +20,31 @@ import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.* import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -31,9 +53,12 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
@@ -48,21 +73,37 @@ import com.geode.launcher.BuildConfig
import com.geode.launcher.MainActivity import com.geode.launcher.MainActivity
import com.geode.launcher.R import com.geode.launcher.R
import com.geode.launcher.UserDirectoryProvider import com.geode.launcher.UserDirectoryProvider
import com.geode.launcher.updater.ReleaseViewModel import com.geode.launcher.preferences.components.BackupButton
import com.geode.launcher.preferences.components.OptionsButton
import com.geode.launcher.preferences.components.OptionsCard
import com.geode.launcher.preferences.components.OptionsGroup
import com.geode.launcher.preferences.components.OptionsLabel
import com.geode.launcher.preferences.components.OptionsTitle
import com.geode.launcher.preferences.components.SafeModeDialog
import com.geode.launcher.preferences.components.SettingsCard
import com.geode.launcher.preferences.components.SettingsFPSCard
import com.geode.launcher.preferences.components.SettingsRangeCard
import com.geode.launcher.preferences.components.SettingsSelectCard
import com.geode.launcher.preferences.components.SettingsStringSelectCard
import com.geode.launcher.ui.theme.GeodeLauncherTheme import com.geode.launcher.ui.theme.GeodeLauncherTheme
import com.geode.launcher.ui.theme.LocalTheme import com.geode.launcher.ui.theme.LocalTheme
import com.geode.launcher.ui.theme.Theme import com.geode.launcher.ui.theme.Theme
import com.geode.launcher.ui.theme.Typography
import com.geode.launcher.ui.theme.robotoMonoFamily
import com.geode.launcher.updater.ReleaseViewModel
import com.geode.launcher.utils.ApplicationIcon import com.geode.launcher.utils.ApplicationIcon
import com.geode.launcher.utils.Constants import com.geode.launcher.utils.Constants
import com.geode.launcher.utils.GamePackageUtils import com.geode.launcher.utils.GamePackageUtils
import com.geode.launcher.utils.IconUtils import com.geode.launcher.utils.IconUtils
import com.geode.launcher.utils.LaunchUtils import com.geode.launcher.utils.LaunchUtils
import com.geode.launcher.utils.PreferenceUtils import com.geode.launcher.utils.PreferenceUtils
import kotlinx.coroutines.launch
import java.net.ConnectException import java.net.ConnectException
import java.net.UnknownHostException import java.net.UnknownHostException
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.time.Clock import kotlin.time.Clock
class SettingsActivity : ComponentActivity() { class SettingsActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() enableEdgeToEdge()
@@ -119,6 +160,53 @@ fun onOpenFileManager(context: Context) {
} }
} }
@Composable
fun UpdateErrorDialog(
error: Exception,
onDismiss: () -> Unit
) {
val clipboard = LocalClipboard.current
val coroutineScope = rememberCoroutineScope()
val clipboardLabel = stringResource(R.string.application_log_copy_label)
AlertDialog(
icon = {
Icon(
painterResource(R.drawable.icon_warning),
contentDescription = null,
)
},
title = { Text(stringResource(R.string.preference_check_for_updates_help_title)) },
text = {
SelectionContainer {
Text(
error.stackTraceToString(),
fontFamily = robotoMonoFamily,
style = Typography.bodyMedium,
modifier = Modifier.verticalScroll(rememberScrollState())
)
}
},
dismissButton = {
TextButton(onClick = {
coroutineScope.launch {
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText(clipboardLabel, error.stackTraceToString()))
)
}
}) {
Text(stringResource(R.string.preference_check_for_updates_help_copy))
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.message_box_accept))
}
},
onDismissRequest = onDismiss
)
}
@Composable @Composable
fun UpdateIndicator( fun UpdateIndicator(
snackbarHostState: SnackbarHostState, snackbarHostState: SnackbarHostState,
@@ -128,6 +216,17 @@ fun UpdateIndicator(
val resources = LocalResources.current val resources = LocalResources.current
var enablePopup by remember { mutableStateOf(false) } var enablePopup by remember { mutableStateOf(false) }
var enableHelp by remember { mutableStateOf(false) }
var currentError by remember { mutableStateOf<Exception?>(null) }
val currentErrorS = currentError
if (currentErrorS != null && enableHelp) {
UpdateErrorDialog(currentErrorS) {
enableHelp = false
}
}
when (updateStatus) { when (updateStatus) {
is ReleaseViewModel.ReleaseUIState.InUpdateCheck -> { is ReleaseViewModel.ReleaseUIState.InUpdateCheck -> {
@@ -165,7 +264,12 @@ fun UpdateIndicator(
else -> resources.getString(R.string.preference_check_for_updates_failed) else -> resources.getString(R.string.preference_check_for_updates_failed)
} }
snackbarHostState.showSnackbar(message) currentError = updateStatus.exception
val snackbarResult = snackbarHostState.showSnackbar(message, resources.getString(R.string.preference_check_for_updates_failed_action))
if (snackbarResult == SnackbarResult.ActionPerformed) {
enableHelp = true
}
} }
is ReleaseViewModel.ReleaseUIState.Finished -> { is ReleaseViewModel.ReleaseUIState.Finished -> {
if (updateStatus.hasUpdated) { if (updateStatus.hasUpdated) {
@@ -374,6 +478,33 @@ fun GameplaySettingsGroup() {
preferenceKey = PreferenceUtils.Key.LIMIT_FRAME_RATE, preferenceKey = PreferenceUtils.Key.LIMIT_FRAME_RATE,
maxFrameRate = maxFrameRate maxFrameRate = maxFrameRate
) )
var showSafeModeDialog by remember { mutableStateOf(false) }
OptionsButton(
title = stringResource(R.string.preference_launch_safe_mode),
icon = {
Icon(
painterResource(R.drawable.icon_shield),
contentDescription = null
)
}
) {
showSafeModeDialog = true
}
if (showSafeModeDialog) {
SafeModeDialog(onDismiss = {
showSafeModeDialog = false
}) {
val launchIntent = Intent(context, MainActivity::class.java).apply {
// hardcode it, why not
action = Intent.ACTION_VIEW
data = "geode-launcher://main/launch?safe-mode=true".toUri()
}
context.startActivity(launchIntent)
}
}
} }
} }
@@ -412,7 +543,10 @@ fun UpdateSettingsGroup(releaseViewModel: ReleaseViewModel, snackbarHostState: S
contentDescription = null contentDescription = null
) )
}, },
description = stringResource(R.string.preference_check_for_updates_description, checkAgoString) description = stringResource(
R.string.preference_check_for_updates_description,
checkAgoString
)
.takeIf { lastUpdateCheck != 0L } .takeIf { lastUpdateCheck != 0L }
) )
}, },
@@ -430,7 +564,7 @@ fun UpdateSettingsGroup(releaseViewModel: ReleaseViewModel, snackbarHostState: S
} }
@Composable @Composable
fun AdvancedSettingsGroup(developerModeEnabled: Boolean) { fun AdvancedSettingsGroup(developerModeEnabled: Boolean, snackbarHostState: SnackbarHostState) {
val context = LocalContext.current val context = LocalContext.current
OptionsGroup(stringResource(R.string.preference_category_developer)) { OptionsGroup(stringResource(R.string.preference_category_developer)) {
@@ -461,32 +595,7 @@ fun AdvancedSettingsGroup(developerModeEnabled: Boolean) {
} }
) )
var showSafeModeDialog by remember { mutableStateOf(false) } BackupButton(snackbarHostState)
OptionsButton(
title = stringResource(R.string.preference_launch_safe_mode),
icon = {
Icon(
painterResource(R.drawable.icon_shield),
contentDescription = null
)
}
) {
showSafeModeDialog = true
}
if (showSafeModeDialog) {
SafeModeDialog(onDismiss = {
showSafeModeDialog = false
}) {
val launchIntent = Intent(context, MainActivity::class.java).apply {
// hardcode it, why not
action = Intent.ACTION_VIEW
data = "geode-launcher://main/launch?safe-mode=true".toUri()
}
context.startActivity(launchIntent)
}
}
} }
} }
@@ -502,14 +611,21 @@ fun AboutSettingsGroup(developerModeEnabled: Boolean) {
OptionsButton( OptionsButton(
stringResource(R.string.preference_launcher_version_name), stringResource(R.string.preference_launcher_version_name),
stringResource(R.string.preference_launcher_version_description, BuildConfig.VERSION_NAME), stringResource(
R.string.preference_launcher_version_description,
BuildConfig.VERSION_NAME
),
displayInline = true displayInline = true
) { ) {
launcherButtonTapped += 1 launcherButtonTapped += 1
if (launcherButtonTapped >= 7) { if (launcherButtonTapped >= 7) {
if (developerModeEnabled) { if (developerModeEnabled) {
Toast.makeText(context, R.string.preference_enable_developer_again, Toast.LENGTH_SHORT) Toast.makeText(
context,
R.string.preference_enable_developer_again,
Toast.LENGTH_SHORT
)
.show() .show()
} else { } else {
showDeveloperDialog = true showDeveloperDialog = true
@@ -524,7 +640,8 @@ fun AboutSettingsGroup(developerModeEnabled: Boolean) {
showDeveloperDialog = false showDeveloperDialog = false
}, },
onEnable = { onEnable = {
PreferenceUtils.get(context).setBoolean(PreferenceUtils.Key.DEVELOPER_MODE, true) PreferenceUtils.get(context)
.setBoolean(PreferenceUtils.Key.DEVELOPER_MODE, true)
showDeveloperDialog = false showDeveloperDialog = false
} }
) )
@@ -532,7 +649,10 @@ fun AboutSettingsGroup(developerModeEnabled: Boolean) {
OptionsLabel( OptionsLabel(
stringResource(R.string.preference_loader_version_name), stringResource(R.string.preference_loader_version_name),
stringResource(R.string.preference_loader_version_description, currentRelease ?: "unknown"), stringResource(
R.string.preference_loader_version_description,
currentRelease ?: "unknown"
),
displayInline = true displayInline = true
) )
@@ -546,22 +666,32 @@ fun AboutSettingsGroup(developerModeEnabled: Boolean) {
} }
val gameSource = remember { val gameSource = remember {
resources.getString(when (GamePackageUtils.identifyGameSource(context.packageManager)) { resources.getString(
GamePackageUtils.GameSource.GOOGLE -> R.string.preference_game_source_google when (GamePackageUtils.identifyGameSource(context.packageManager)) {
GamePackageUtils.GameSource.AMAZON -> R.string.preference_game_source_amazon GamePackageUtils.GameSource.GOOGLE -> R.string.preference_game_source_google
else -> R.string.preference_game_source_unknown GamePackageUtils.GameSource.AMAZON -> R.string.preference_game_source_amazon
}) else -> R.string.preference_game_source_unknown
}
)
} }
OptionsLabel( OptionsLabel(
stringResource(R.string.preference_game_version_name), stringResource(R.string.preference_game_version_name),
stringResource(R.string.preference_game_version_description, gameVersion, gameSource), stringResource(
R.string.preference_game_version_description,
gameVersion,
gameSource
),
displayInline = true displayInline = true
) )
OptionsLabel( OptionsLabel(
stringResource(R.string.preference_architecture_name), stringResource(R.string.preference_architecture_name),
stringResource(R.string.preference_architecture_description, Build.MODEL, LaunchUtils.applicationArchitecture), stringResource(
R.string.preference_architecture_description,
Build.MODEL,
LaunchUtils.applicationArchitecture
),
displayInline = true displayInline = true
) )
} }
@@ -644,7 +774,7 @@ fun SettingsScreen(
val developerModeEnabled by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DEVELOPER_MODE) val developerModeEnabled by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DEVELOPER_MODE)
AdvancedSettingsGroup(developerModeEnabled) AdvancedSettingsGroup(developerModeEnabled, snackbarHostState)
AboutSettingsGroup(developerModeEnabled) AboutSettingsGroup(developerModeEnabled)
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
@@ -0,0 +1,196 @@
package com.geode.launcher.preferences.components
import androidx.activity.OnBackPressedDispatcher
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.geode.launcher.R
import com.geode.launcher.log.BaseDirectoryViewModel
import com.geode.launcher.ui.theme.Typography
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CrashLogsTopBar(
onBackPressedDispatcher: OnBackPressedDispatcher?,
directoryViewModel: BaseDirectoryViewModel<*>,
scrollBehavior: TopAppBarScrollBehavior,
@StringRes titleId: Int,
additionalOptions: (@Composable (() -> Unit) -> Unit)? = null,
) {
val isLoading by directoryViewModel.isLoading.collectAsState()
var showMoreOptions by remember { mutableStateOf(false) }
Column {
TopAppBar(
navigationIcon = {
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
Icon(
painterResource(R.drawable.icon_arrow_back),
contentDescription = stringResource(R.string.back_icon_alt)
)
}
},
actions = {
IconButton(onClick = { showMoreOptions = !showMoreOptions }) {
Icon(
painterResource(R.drawable.icon_more_vert),
contentDescription = stringResource(R.string.application_logs_more)
)
}
DropdownMenu(
expanded = showMoreOptions,
onDismissRequest = { showMoreOptions = false }
) {
DropdownMenuItem(
leadingIcon = {
Icon(
painterResource(R.drawable.icon_delete),
contentDescription = null
)
},
text = {
Text(stringResource(R.string.application_logs_delete))
}, onClick = {
directoryViewModel.clearAllFiles()
showMoreOptions = false
}
)
additionalOptions?.invoke {
showMoreOptions = false
}
}
},
title = {
Text(
stringResource(titleId),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
scrollBehavior = scrollBehavior,
)
if (isLoading) {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth()
)
}
}
}
@Composable
fun <T> CrashLogsListing(
modifier: Modifier = Modifier,
directoryViewModel: BaseDirectoryViewModel<T>,
@StringRes noneAvailableId: Int,
logCard: @Composable (T, Shape) -> Unit,
) {
val logLines = directoryViewModel.lineState
val isLoading by directoryViewModel.isLoading.collectAsState()
LazyColumn(
modifier,
verticalArrangement = Arrangement.SpaceEvenly,
) {
if (!isLoading && logLines.isEmpty()) {
item {
Text(
stringResource(noneAvailableId),
textAlign = TextAlign.Center,
style = Typography.bodyLarge,
modifier = Modifier.fillMaxSize().padding(8.dp)
)
}
}
val itemsSize = logLines.size
itemsIndexed(
logLines,
key = { index, line -> directoryViewModel.getItemKey(line) }
) { index, line ->
val shape = RoundedCornerShape(
topStart = if (index == 0) 16.dp else 4.dp,
topEnd = if (index == 0) 16.dp else 4.dp,
bottomStart = if (index + 1 == itemsSize) 16.dp else 4.dp,
bottomEnd = if (index + 1 == itemsSize) 16.dp else 4.dp
)
logCard(line, shape)
}
item {
Spacer(Modifier.height(4.dp))
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun <T> DirectoryListingScreen(
onBackPressedDispatcher: OnBackPressedDispatcher?,
directoryViewModel: BaseDirectoryViewModel<T>,
@StringRes titleId: Int,
@StringRes noneAvailableId: Int,
additionalOptions: (@Composable (() -> Unit) -> Unit)? = null,
logCard: @Composable (T, Shape) -> Unit,
) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
CrashLogsTopBar(
onBackPressedDispatcher,
directoryViewModel,
scrollBehavior,
titleId,
additionalOptions = additionalOptions
)
}
) { innerPadding ->
CrashLogsListing(
modifier = Modifier
.padding(innerPadding)
.fillMaxWidth(),
directoryViewModel,
noneAvailableId = noneAvailableId,
logCard = logCard
)
}
}
@@ -0,0 +1,482 @@
package com.geode.launcher.preferences.components
import android.content.Context
import android.util.Log
import android.util.Xml
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import com.geode.launcher.R
import com.geode.launcher.utils.LaunchUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import okhttp3.FormBody
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.coroutines.executeAsync
import okio.Buffer
import okio.ByteString.Companion.decodeBase64
import okio.GzipSource
import okio.buffer
import okio.source
import org.xmlpull.v1.XmlPullParser
import java.io.File
import kotlin.experimental.xor
import kotlin.time.Duration.Companion.seconds
const val TAG = "com.geode.launcher.SaveBackup"
data class UserDetails(
val userName: String,
val gjp2: String,
val accountId: Int,
val uuid: Int,
val udid: String,
val binaryVersion: Int,
)
enum class BackupParseFailure {
SAVE_MISSING,
DECODE_FAILED,
DETAILS_MISSING,
SAVE_TOO_LARGE
}
class BackupParseException(val failureCode: BackupParseFailure) : Exception("parse error: $failureCode")
enum class BackupUploadFailure {
GENERIC_ERROR,
NETWORK_ERROR,
INVALID_LOGIN
}
class BackupUploadException(val failureCode: BackupUploadFailure, val gjCode: Int = -1) : Exception("upload error: $failureCode")
suspend fun getAccountBackupUrl(backupClient: OkHttpClient, details: UserDetails): Result<HttpUrl> {
return try {
// accountID=%i&type=1&secret=Wmfd2893gb7
val body = FormBody.Builder()
.add("secret", "Wmfd2893gb7")
.add("type", "1")
.add("accountID", details.accountId.toString())
.build()
val request = Request.Builder()
.url("https://www.boomlings.com/database/getAccountURL.php")
.addHeader("User-Agent", "")
.post(body)
.build()
val call = backupClient.newCall(request)
call.executeAsync()
} catch (e: Exception) {
Log.w(TAG, "getAccountBackupUrl failed: ${e.stackTraceToString()}")
return Result.failure(BackupUploadException(BackupUploadFailure.NETWORK_ERROR))
}.use { response ->
if (response.code != 200) {
return Result.failure(BackupUploadException(BackupUploadFailure.GENERIC_ERROR))
}
val url = response.body.string().toHttpUrlOrNull()
?: return Result.failure(BackupUploadException(BackupUploadFailure.GENERIC_ERROR))
Result.success(url)
}
}
fun decryptFileToBuffer(file: File, buffer: Buffer) = file.source().buffer()
.use { baseBuffer ->
val key = 0xb.toByte()
val nullByte = 0x0.toByte()
while (!baseBuffer.exhausted()) {
val b = baseBuffer.readByte()
val d = b.xor(key)
// gd sometimes encodes garbage after null byte, treat it like a c string
if (d == nullByte)
break
buffer.writeByte(d.toInt())
}
}
fun generateSaveString(gameManager: File, localLevels: File): String = Buffer()
.use { saveBuffer ->
decryptFileToBuffer(gameManager, saveBuffer)
saveBuffer.writeByte(';'.code)
decryptFileToBuffer(localLevels, saveBuffer)
saveBuffer.readUtf8()
}
fun generateBackupRequest(details: UserDetails, url: HttpUrl, gameManager: File, localLevels: File): Request {
val saveData = generateSaveString(gameManager, localLevels)
// %s/database/accounts/backupGJAccountNew.php
val backupUrl = url.newBuilder()
.addPathSegments("database/accounts/backupGJAccountNew.php")
.build()
// gameVersion=22&binaryVersion=%i&dvs=2&udid=%s&uuid=%i&accountID=%i&gjp2=%s&secret=Wmfv3899gc9&saveData=%s
val body = FormBody.Builder()
.add("gameVersion", "22")
.add("binaryVersion", details.binaryVersion.toString())
.add("dvs", "2")
.add("udid", details.udid)
.add("uuid", details.uuid.toString())
.add("accountID", details.accountId.toString())
.add("secret", "Wmfv3899gc9")
.add("gjp2", details.gjp2)
.add("saveData", saveData)
.build()
return Request.Builder()
.url(backupUrl)
.addHeader("User-Agent", "")
.post(body)
.build()
}
suspend fun performBackupRequest(backupClient: OkHttpClient, details: UserDetails, url: HttpUrl, gameManager: File, localLevels: File): Result<Unit> {
return try {
// we split this into multiple functions to free up as many references as we can
// go little gc go
backupClient.newCall(
generateBackupRequest(details, url, gameManager, localLevels)
).executeAsync()
} catch (e: Exception) {
Log.w(TAG, "performBackupRequest failed: ${e.stackTraceToString()}")
return Result.failure(BackupUploadException(BackupUploadFailure.GENERIC_ERROR))
}.use { response ->
if (response.code != 200) {
return Result.failure(BackupUploadException(BackupUploadFailure.GENERIC_ERROR))
}
val responseBody = response.body.string().toIntOrNull()
?: return Result.failure(BackupUploadException(BackupUploadFailure.GENERIC_ERROR))
when (responseBody) {
1 -> Result.success(Unit)
-5 -> Result.failure(BackupUploadException(BackupUploadFailure.INVALID_LOGIN, responseBody))
else -> Result.failure(BackupUploadException(BackupUploadFailure.GENERIC_ERROR, responseBody))
}
}
}
fun skip(parser: XmlPullParser) {
if (parser.eventType != XmlPullParser.START_TAG) {
throw IllegalStateException()
}
var depth = 1
while (depth != 0) {
when (parser.next()) {
XmlPullParser.END_TAG -> depth--
XmlPullParser.START_TAG -> depth++
}
}
}
fun getNextValue(parser: XmlPullParser): String? {
while (parser.eventType != XmlPullParser.START_TAG && parser.eventType != XmlPullParser.END_DOCUMENT) {
parser.next()
}
if (parser.eventType == XmlPullParser.START_TAG) {
// we could support t/f here but we also don't have to
if (parser.name != "s" && parser.name != "i") {
return null
}
return parser.nextText()
}
return null
}
fun parseSaveFile(parser: XmlPullParser): Result<UserDetails> = try {
var userName: String? = null
var gjp2: String? = null
var accountId: Int? = null
var uuid: Int? = null
var udid: String? = null
var binaryVersion: Int? = null
var inPlist = false
var inDict = false
var eventType = parser.eventType
while (eventType != XmlPullParser.END_DOCUMENT) {
when (eventType) {
XmlPullParser.START_TAG -> {
if (inDict) {
// ignore inner elements, just in case they conflict
if (parser.name != "k") {
skip(parser)
} else {
val keyName = parser.nextText()
when (keyName) {
"GJA_001" -> userName = getNextValue(parser)
"GJA_003" -> accountId = getNextValue(parser)?.toIntOrNull()
"GJA_005" -> gjp2 = getNextValue(parser)
"playerUserID" -> uuid = getNextValue(parser)?.toIntOrNull()
"playerUDID" -> udid = getNextValue(parser)
"binaryVersion" -> binaryVersion = getNextValue(parser)?.toIntOrNull()
}
}
}
// traverse to initial plist -> dict element
if (!inPlist && parser.name == "plist") {
inPlist = true
}
if (!inDict && parser.name == "dict") {
inDict = true
}
}
}
eventType = parser.next()
}
if (userName == null || gjp2 == null || accountId == null || uuid == null || udid == null || binaryVersion == null) {
Result.failure(BackupParseException(BackupParseFailure.DETAILS_MISSING))
} else {
Result.success(
UserDetails(
userName = userName,
gjp2 = gjp2,
accountId = accountId,
uuid = uuid,
udid = udid,
binaryVersion = binaryVersion
)
)
}
} catch (e: Exception) {
Log.w(TAG, "parseSaveFile failed: ${e.stackTraceToString()}")
Result.failure(BackupParseException(BackupParseFailure.DECODE_FAILED))
}
fun decryptFileToString(file: File) = Buffer()
.use { decryptedBuffer ->
decryptFileToBuffer(file, decryptedBuffer)
decryptedBuffer.readUtf8()
}
suspend fun getUserDetails(gameManager: File): Result<UserDetails> {
return try {
val base64Decoded = run {
// free decryptString after decoding
val decryptString = decryptFileToString(gameManager)
decryptString.decodeBase64()
} ?: return Result.failure(BackupParseException(BackupParseFailure.DECODE_FAILED))
// gzipSource will close the underlying buffer when closed
GzipSource(
Buffer().write(base64Decoded)
).buffer().use { gzipSource ->
runInterruptible {
val parser = Xml.newPullParser()
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
parser.setInput(gzipSource.inputStream(), null)
parseSaveFile(parser)
}
}
} catch (e: Exception) {
Log.w(TAG, "getUserDetails failed: ${e.stackTraceToString()}")
return Result.failure(BackupParseException(BackupParseFailure.DECODE_FAILED))
}
}
const val MB_TO_BYTES = 1048576 // 2^20
suspend fun backupSaveToCloud(context: Context): Result<Unit> {
val baseDirectory = LaunchUtils.getBaseDirectory(context)
val gameManager = File(baseDirectory,"/save/CCGameManager.dat")
val localLevels = File(baseDirectory, "/save/CCLocalLevels.dat")
if (!gameManager.isFile || !localLevels.isFile) {
return Result.failure(BackupParseException(BackupParseFailure.SAVE_MISSING))
}
// hardcode 32mb max size
// this is how the game checks it, which probably isn't the best
val totalSize = (gameManager.length() + localLevels.length()) / MB_TO_BYTES.toDouble()
println("Backup - calculated size of %.2fmb".format(totalSize))
if (totalSize > 32.0) {
return Result.failure(BackupParseException(BackupParseFailure.SAVE_TOO_LARGE))
}
return withContext(Dispatchers.IO) {
val userDetails = getUserDetails(gameManager).getOrElse {
return@withContext Result.failure(it)
}
println("Backup - performing as ${userDetails.userName}, accountID=${userDetails.accountId}")
val backupClient = OkHttpClient.Builder()
.writeTimeout(500.seconds)
.build()
val backupUrl = getAccountBackupUrl(backupClient, userDetails).getOrElse {
return@withContext Result.failure(it)
}
performBackupRequest(backupClient, userDetails, backupUrl, gameManager, localLevels).getOrElse {
return@withContext Result.failure(it)
}
println("Backup completed!")
Result.success(Unit)
}
}
@Composable
fun BackupConfirmDialog(onDismiss: () -> Unit, onEnable: () -> Unit) {
AlertDialog(
icon = {
Icon(
painterResource(R.drawable.icon_cloud_alert),
contentDescription = null,
)
},
title = { Text(stringResource(R.string.preference_backup_confirm_title)) },
text = { Text(stringResource(R.string.preference_backup_confirm_description)) },
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.message_box_cancel))
}
},
confirmButton = {
TextButton(onClick = onEnable) {
Text(stringResource(R.string.message_box_continue))
}
},
onDismissRequest = onDismiss
)
}
fun messageFromResult(context: Context, result: Result<Unit>): String {
val exception = result.exceptionOrNull()
?: return context.getString(R.string.preference_backup_success)
return when (exception) {
is BackupParseException -> when (exception.failureCode) {
BackupParseFailure.SAVE_MISSING -> context.getString(R.string.preference_backup_save_missing)
BackupParseFailure.DECODE_FAILED -> context.getString(R.string.preference_backup_save_decode_failed)
BackupParseFailure.DETAILS_MISSING -> context.getString(R.string.preference_backup_save_details_missing)
BackupParseFailure.SAVE_TOO_LARGE -> context.getString(R.string.preference_backup_save_too_large)
}
is BackupUploadException -> when (exception.failureCode) {
BackupUploadFailure.GENERIC_ERROR -> context.getString(R.string.preference_backup_failed, exception.gjCode)
BackupUploadFailure.NETWORK_ERROR -> context.getString(R.string.preference_backup_failed_network)
BackupUploadFailure.INVALID_LOGIN -> context.getString(R.string.preference_backup_failed_invalid_login)
}
else -> context.getString(R.string.preference_backup_failed_generic)
}
}
@Composable
fun BackupDialog(onDismiss: () -> Unit, isCancelling: Boolean) {
AlertDialog(
icon = {
Icon(
painterResource(R.drawable.icon_cloud_upload),
contentDescription = null
)
},
title = {
if (isCancelling) {
Text(stringResource(R.string.preference_backup_cancelling))
} else {
Text(stringResource(R.string.preference_backup_in_progress))
}
},
text = {
LinearProgressIndicator()
},
confirmButton = {
TextButton(onClick = onDismiss, enabled = !isCancelling) {
Text(stringResource(R.string.message_box_cancel))
}
},
onDismissRequest = {}
)
}
@Composable
fun BackupButton(snackbarHostState: SnackbarHostState) {
val context = LocalContext.current
var showBackupDialog by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
var backupInProgress by remember { mutableStateOf(false) }
var backupJob by remember { mutableStateOf<Job?>(null) }
OptionsButton(
title = stringResource(R.string.preference_backup_cloud),
icon = {
Icon(
painterResource(R.drawable.icon_cloud_upload),
contentDescription = null
)
},
) {
showBackupDialog = true
}
if (showBackupDialog) {
BackupConfirmDialog(onDismiss = {
showBackupDialog = false
}) {
showBackupDialog = false
backupJob = coroutineScope.launch {
backupInProgress = true
val responseStatus = backupSaveToCloud(context)
backupInProgress = false
snackbarHostState.showSnackbar(messageFromResult(context, responseStatus))
}
}
}
var isCancelling by remember { mutableStateOf(false) }
if (backupInProgress) {
BackupDialog(onDismiss = {
coroutineScope.launch {
isCancelling = true
backupJob?.cancelAndJoin()
isCancelling = false
backupInProgress = false
}
}, isCancelling)
}
}
@@ -1,4 +1,4 @@
package com.geode.launcher.preferences package com.geode.launcher.preferences.components
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@@ -8,41 +8,28 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
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.draw.clip
@@ -51,21 +38,17 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
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.window.Dialog
import com.geode.launcher.R import com.geode.launcher.R
import com.geode.launcher.ui.theme.GeodeLauncherTheme import com.geode.launcher.ui.theme.GeodeLauncherTheme
import com.geode.launcher.ui.theme.Typography import com.geode.launcher.ui.theme.Typography
import com.geode.launcher.utils.LabelledText import com.geode.launcher.utils.LabelledText
import com.geode.launcher.utils.PreferenceUtils import com.geode.launcher.utils.PreferenceUtils
import com.geode.launcher.utils.Profile
import com.geode.launcher.utils.ProfileManager import com.geode.launcher.utils.ProfileManager
import kotlin.collections.get
import kotlin.math.log10 import kotlin.math.log10
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.system.exitProcess import kotlin.system.exitProcess
@@ -237,244 +220,6 @@ fun SettingsStringCard(
} }
} }
@Composable
fun StringDialog(
title: String,
onDismissRequest: () -> Unit,
onSelect: (String) -> Unit,
initialValue: String,
filterInput: ((String) -> String)? = null
) {
var enteredValue by remember { mutableStateOf(initialValue) }
BaseDialog(
onDismissRequest = { onDismissRequest() },
title = title,
content = {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
OutlinedTextField(
value = enteredValue,
onValueChange = {
enteredValue = filterInput?.invoke(it) ?: it
},
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Ascii,
autoCorrectEnabled = false
),
trailingIcon = {
if (enteredValue.isNotEmpty()) {
IconButton(onClick = { enteredValue = "" }) {
Icon(
painterResource(R.drawable.icon_close_small),
contentDescription = stringResource(R.string.preference_text_clear)
)
}
}
}
)
}
},
confirmButton = {
TextButton(onClick = { onSelect(enteredValue) }) {
Text(stringResource(R.string.message_box_accept))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.message_box_cancel))
}
},
)
}
@Composable
fun RangeDialog(
title: String,
onDismissRequest: () -> Unit,
onSelect: (Int) -> Unit,
labelSuffix: String,
initialValue: Int,
range: IntRange,
scale: Int,
step: Int,
children: @Composable () -> Unit
) {
var enteredValue by remember {
mutableFloatStateOf(initialValue / scale.toFloat())
}
BaseDialog(
onDismissRequest = { onDismissRequest() },
title = title,
content = {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
) {
val precision = log10(scale.toFloat()).roundToInt()
Slider(
value = enteredValue,
onValueChange = { enteredValue = it },
valueRange = (range.first.toFloat()/scale)..(range.last.toFloat()/scale),
steps = ((range.last - range.first) - 1) / step,
modifier = Modifier.weight(1.0f)
)
Text("%.${precision}f$labelSuffix".format(enteredValue))
}
children()
},
confirmButton = {
TextButton(onClick = { onSelect((enteredValue * scale).roundToInt()) }) {
Text(stringResource(R.string.message_box_accept))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.message_box_cancel))
}
},
)
}
@Composable
fun FrameRateDialog(
title: String,
onDismissRequest: () -> Unit,
onSelect: (Int) -> Unit,
initialValue: Int,
maxFrameRate: Int,
) {
var enteredValue by remember {
mutableStateOf(
if (initialValue == 0) maxFrameRate.toString()
else initialValue.toString()
)
}
val minFrameRate = 5
val currentValue = enteredValue.toIntOrNull()
val maximumReached = currentValue != null && currentValue > maxFrameRate
val minimumReached = currentValue != null && currentValue < minFrameRate
BaseDialog(
onDismissRequest = { onDismissRequest() },
title = title,
content = {
Column(
modifier = Modifier
.padding(horizontal = 12.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = {
val prevValue = max((currentValue ?: 0) - 5, minFrameRate)
enteredValue = prevValue.toString()
}, enabled = currentValue == null || currentValue > minFrameRate) {
Icon(
painterResource(R.drawable.icon_remove),
contentDescription = stringResource(R.string.preference_limit_framerate_subtract)
)
}
Spacer(Modifier.size(8.dp))
OutlinedTextField(
value = enteredValue,
onValueChange = {
enteredValue = it
},
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
),
suffix = {
Text(stringResource(R.string.preference_limit_framerate_suffix))
},
label = {
Text(stringResource(R.string.preference_limit_framerate_label))
},
isError = minimumReached || maximumReached,
modifier = Modifier.weight(1.0f),
)
Spacer(Modifier.size(8.dp))
IconButton(onClick = {
val nextValue = min((currentValue ?: 0) + 5, maxFrameRate)
enteredValue = nextValue.toString()
}, enabled = currentValue == null || currentValue < maxFrameRate) {
Icon(
painterResource(R.drawable.icon_add),
contentDescription = stringResource(R.string.preference_limit_framerate_add)
)
}
IconButton(onClick = { onSelect(0) }) {
Icon(
painterResource(R.drawable.icon_delete),
contentDescription = stringResource(R.string.preference_limit_framerate_reset)
)
}
}
if (minimumReached) {
Spacer(Modifier.size(8.dp))
Text(
stringResource(R.string.preference_limit_framerate_error_min, minFrameRate),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.labelLarge,
)
} else if (maximumReached) {
Spacer(Modifier.size(8.dp))
Text(
stringResource(
R.string.preference_limit_framerate_error_max,
maxFrameRate
),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.labelLarge,
)
}
}
},
confirmButton = {
TextButton(
onClick = {
// automatically disable if already max
if (currentValue == maxFrameRate) {
onSelect(0)
} else {
onSelect(currentValue ?: 0)
}
},
enabled = !minimumReached && !maximumReached && currentValue != null
) {
Text(stringResource(R.string.message_box_accept))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.message_box_cancel))
}
},
)
}
@Composable @Composable
fun SettingsFPSCard( fun SettingsFPSCard(
title: String, title: String,
@@ -570,120 +315,6 @@ fun SettingsRangeCard(
} }
} }
internal val LocalSelectValue = compositionLocalOf<Any> { 0 }
internal val LocalSelectSetValue = staticCompositionLocalOf<(Any) -> Unit> { {} }
@Composable
fun <T> SelectOption(name: String, value: T, enabled: Boolean = true, leadingContent: @Composable (Boolean) -> Unit = {}) {
val currentValue = LocalSelectValue.current
val setValue = LocalSelectSetValue.current
val isSelected = currentValue.equals(value)
// do not give the row or column padding!! it messes up the selection effect
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(
onClick = { setValue(value as Any) },
role = Role.RadioButton,
enabled = enabled
)
.padding(horizontal = 12.dp)
) {
Row(modifier = Modifier.weight(1.0f, true), verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = isSelected,
onClick = { setValue(value as Any) },
enabled = enabled
)
Text(name, style = Typography.bodyMedium)
}
leadingContent(isSelected)
}
}
@Composable
fun ProfileCreateDialog(onDismissRequest: () -> Unit) {
var enteredValue by remember { mutableStateOf("") }
val filename = enteredValue.take(16)
.lowercase()
.map {
if ("qwertyuiopasdfghjklzxcvbnm1234567890-_.".contains(it))
it else '_'
}
.joinToString("")
val context = LocalContext.current
val minimumReached = enteredValue.isEmpty() || filename.isEmpty()
val profileManager = ProfileManager.get(context)
val currentProfiles = remember {
profileManager.getProfiles().map { it.path }.toSet()
}
val isDuplicate = currentProfiles.contains(filename)
BaseDialog(
onDismissRequest = { onDismissRequest() },
title = stringResource(R.string.preference_profiles_create),
content = {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
) {
OutlinedTextField(
value = enteredValue,
onValueChange = {
enteredValue = it
},
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
),
label = {
Text(stringResource(R.string.preference_profiles_create_name))
},
isError = isDuplicate,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
if (isDuplicate) {
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.preference_profiles_create_duplicate))
} else if (!minimumReached) {
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.preference_profiles_create_info, filename))
}
}
},
confirmButton = {
TextButton(
onClick = {
ProfileManager.get(context).storeProfile(Profile(filename, enteredValue))
onDismissRequest()
},
enabled = !minimumReached && !isDuplicate
) {
Text(stringResource(R.string.preference_profiles_create_action))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.message_box_cancel))
}
},
)
}
@Composable @Composable
fun ProfileCreateCard() { fun ProfileCreateCard() {
var showDialog by remember { mutableStateOf(false) } var showDialog by remember { mutableStateOf(false) }
@@ -713,7 +344,7 @@ fun ProfileSelectCard() {
profileManager.getCurrentProfile() profileManager.getCurrentProfile()
} }
var currentProfileName = remember { val currentProfileName = remember {
val savedProfile = currentProfileId val savedProfile = currentProfileId
if (savedProfile == null) { if (savedProfile == null) {
"Default" "Default"
@@ -803,90 +434,6 @@ fun ProfileSelectCard() {
} }
} }
@Composable
fun BaseDialog(
title: String,
onDismissRequest: () -> Unit,
dismissButton: @Composable () -> Unit,
confirmButton: @Composable () -> Unit,
content: @Composable (ColumnScope.() -> Unit),
) {
Dialog(onDismissRequest = onDismissRequest) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
)
) {
// styling a dialog is actually a little hard if you're doing what i'm doing
// maybe there's a better way to make these padding values...
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
Text(
title,
style = Typography.titleLarge,
modifier = Modifier.padding(
start = 28.dp,
top = 24.dp,
bottom = 12.dp
)
)
content()
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier
.fillMaxWidth()
.padding(
bottom = 16.dp,
end = 16.dp,
top = 4.dp
)
) {
dismissButton()
confirmButton()
}
}
}
}
}
@Composable
fun <T> SelectDialog(
title: String,
onDismissRequest: () -> Unit,
onSelect: (T) -> Unit,
initialValue: T,
options: @Composable () -> Unit,
) {
val (selectedValue, setSelectedValue) = remember { mutableStateOf(initialValue) }
BaseDialog(
title = title,
onDismissRequest = onDismissRequest,
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.message_box_cancel))
}
},
confirmButton = {
TextButton(onClick = { onSelect(selectedValue) }) {
Text(stringResource(R.string.message_box_accept))
}
}
) {
CompositionLocalProvider(
LocalSelectValue provides (selectedValue as Any),
LocalSelectSetValue provides ({
@Suppress("UNCHECKED_CAST")
setSelectedValue(it as T)
})
) {
options()
}
}
}
@Composable @Composable
fun OptionsButton(title: String, description: String? = null, icon: (@Composable () -> Unit)? = null, displayInline: Boolean = false, onClick: () -> Unit) { fun OptionsButton(title: String, description: String? = null, icon: (@Composable () -> Unit)? = null, displayInline: Boolean = false, onClick: () -> Unit) {
OptionsCard( OptionsCard(
@@ -0,0 +1,490 @@
package com.geode.launcher.preferences.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.geode.launcher.R
import com.geode.launcher.ui.theme.Typography
import com.geode.launcher.utils.Profile
import com.geode.launcher.utils.ProfileManager
import kotlin.math.log10
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
@Composable
fun StringDialog(
title: String,
onDismissRequest: () -> Unit,
onSelect: (String) -> Unit,
initialValue: String,
filterInput: ((String) -> String)? = null
) {
var enteredValue by remember { mutableStateOf(initialValue) }
BaseDialog(
onDismissRequest = { onDismissRequest() },
title = title,
content = {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
OutlinedTextField(
value = enteredValue,
onValueChange = {
enteredValue = filterInput?.invoke(it) ?: it
},
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Ascii,
autoCorrectEnabled = false
),
trailingIcon = {
if (enteredValue.isNotEmpty()) {
IconButton(onClick = { enteredValue = "" }) {
Icon(
painterResource(R.drawable.icon_close_small),
contentDescription = stringResource(R.string.preference_text_clear)
)
}
}
}
)
}
},
confirmButton = {
TextButton(onClick = { onSelect(enteredValue) }) {
Text(stringResource(R.string.message_box_accept))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.message_box_cancel))
}
},
)
}
@Composable
fun RangeDialog(
title: String,
onDismissRequest: () -> Unit,
onSelect: (Int) -> Unit,
labelSuffix: String,
initialValue: Int,
range: IntRange,
scale: Int,
step: Int,
children: @Composable () -> Unit
) {
var enteredValue by remember {
mutableFloatStateOf(initialValue / scale.toFloat())
}
BaseDialog(
onDismissRequest = { onDismissRequest() },
title = title,
content = {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
) {
val precision = log10(scale.toFloat()).roundToInt()
Slider(
value = enteredValue,
onValueChange = { enteredValue = it },
valueRange = (range.first.toFloat()/scale)..(range.last.toFloat()/scale),
steps = ((range.last - range.first) - 1) / step,
modifier = Modifier.weight(1.0f)
)
Text("%.${precision}f$labelSuffix".format(enteredValue))
}
children()
},
confirmButton = {
TextButton(onClick = { onSelect((enteredValue * scale).roundToInt()) }) {
Text(stringResource(R.string.message_box_accept))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.message_box_cancel))
}
},
)
}
@Composable
fun FrameRateDialog(
title: String,
onDismissRequest: () -> Unit,
onSelect: (Int) -> Unit,
initialValue: Int,
maxFrameRate: Int,
) {
var enteredValue by remember {
mutableStateOf(
if (initialValue == 0) maxFrameRate.toString()
else initialValue.toString()
)
}
val minFrameRate = 5
val currentValue = enteredValue.toIntOrNull()
val maximumReached = currentValue != null && currentValue > maxFrameRate
val minimumReached = currentValue != null && currentValue < minFrameRate
BaseDialog(
onDismissRequest = { onDismissRequest() },
title = title,
content = {
Column(
modifier = Modifier
.padding(horizontal = 12.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = {
val prevValue = max((currentValue ?: 0) - 5, minFrameRate)
enteredValue = prevValue.toString()
}, enabled = currentValue == null || currentValue > minFrameRate) {
Icon(
painterResource(R.drawable.icon_remove),
contentDescription = stringResource(R.string.preference_limit_framerate_subtract)
)
}
Spacer(Modifier.size(8.dp))
OutlinedTextField(
value = enteredValue,
onValueChange = {
enteredValue = it
},
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
),
suffix = {
Text(stringResource(R.string.preference_limit_framerate_suffix))
},
label = {
Text(stringResource(R.string.preference_limit_framerate_label))
},
isError = minimumReached || maximumReached,
modifier = Modifier.weight(1.0f),
)
Spacer(Modifier.size(8.dp))
IconButton(onClick = {
val nextValue = min((currentValue ?: 0) + 5, maxFrameRate)
enteredValue = nextValue.toString()
}, enabled = currentValue == null || currentValue < maxFrameRate) {
Icon(
painterResource(R.drawable.icon_add),
contentDescription = stringResource(R.string.preference_limit_framerate_add)
)
}
IconButton(onClick = { onSelect(0) }) {
Icon(
painterResource(R.drawable.icon_delete),
contentDescription = stringResource(R.string.preference_limit_framerate_reset)
)
}
}
if (minimumReached) {
Spacer(Modifier.size(8.dp))
Text(
stringResource(R.string.preference_limit_framerate_error_min, minFrameRate),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.labelLarge,
)
} else if (maximumReached) {
Spacer(Modifier.size(8.dp))
Text(
stringResource(
R.string.preference_limit_framerate_error_max,
maxFrameRate
),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.labelLarge,
)
}
}
},
confirmButton = {
TextButton(
onClick = {
// automatically disable if already max
if (currentValue == maxFrameRate) {
onSelect(0)
} else {
onSelect(currentValue ?: 0)
}
},
enabled = !minimumReached && !maximumReached && currentValue != null
) {
Text(stringResource(R.string.message_box_accept))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.message_box_cancel))
}
},
)
}
@Composable
fun ProfileCreateDialog(onDismissRequest: () -> Unit) {
var enteredValue by remember { mutableStateOf("") }
val filename = enteredValue.take(16)
.lowercase()
.map {
if ("qwertyuiopasdfghjklzxcvbnm1234567890-_.".contains(it))
it else '_'
}
.joinToString("")
val context = LocalContext.current
val minimumReached = enteredValue.isEmpty() || filename.isEmpty()
val profileManager = ProfileManager.get(context)
val currentProfiles = remember {
profileManager.getProfiles().map { it.path }.toSet()
}
val isDuplicate = currentProfiles.contains(filename)
BaseDialog(
onDismissRequest = { onDismissRequest() },
title = stringResource(R.string.preference_profiles_create),
content = {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
) {
OutlinedTextField(
value = enteredValue,
onValueChange = {
enteredValue = it
},
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
),
label = {
Text(stringResource(R.string.preference_profiles_create_name))
},
isError = isDuplicate,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
if (isDuplicate) {
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.preference_profiles_create_duplicate))
} else if (!minimumReached) {
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.preference_profiles_create_info, filename))
}
}
},
confirmButton = {
TextButton(
onClick = {
ProfileManager.get(context).storeProfile(Profile(filename, enteredValue))
onDismissRequest()
},
enabled = !minimumReached && !isDuplicate
) {
Text(stringResource(R.string.preference_profiles_create_action))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.message_box_cancel))
}
},
)
}
@Composable
fun BaseDialog(
title: String,
onDismissRequest: () -> Unit,
dismissButton: @Composable () -> Unit,
confirmButton: @Composable () -> Unit,
content: @Composable (ColumnScope.() -> Unit),
) {
Dialog(onDismissRequest = onDismissRequest) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
)
) {
// styling a dialog is actually a little hard if you're doing what i'm doing
// maybe there's a better way to make these padding values...
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
Text(
title,
style = Typography.titleLarge,
modifier = Modifier.padding(
start = 28.dp,
top = 24.dp,
bottom = 12.dp
)
)
content()
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier
.fillMaxWidth()
.padding(
bottom = 16.dp,
end = 16.dp,
top = 4.dp
)
) {
dismissButton()
confirmButton()
}
}
}
}
}
internal val LocalSelectValue = compositionLocalOf<Any> { 0 }
internal val LocalSelectSetValue = staticCompositionLocalOf<(Any) -> Unit> { {} }
@Composable
fun <T> SelectOption(name: String, value: T, enabled: Boolean = true, leadingContent: @Composable (Boolean) -> Unit = {}) {
val currentValue = LocalSelectValue.current
val setValue = LocalSelectSetValue.current
val isSelected = currentValue.equals(value)
// do not give the row or column padding!! it messes up the selection effect
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(
onClick = { setValue(value as Any) },
role = Role.RadioButton,
enabled = enabled
)
.padding(horizontal = 12.dp)
) {
Row(modifier = Modifier.weight(1.0f, true), verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = isSelected,
onClick = { setValue(value as Any) },
enabled = enabled
)
Text(name, style = Typography.bodyMedium)
}
leadingContent(isSelected)
}
}
@Composable
fun <T> SelectDialog(
title: String,
onDismissRequest: () -> Unit,
onSelect: (T) -> Unit,
initialValue: T,
options: @Composable () -> Unit,
) {
val (selectedValue, setSelectedValue) = remember { mutableStateOf(initialValue) }
BaseDialog(
title = title,
onDismissRequest = onDismissRequest,
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.message_box_cancel))
}
},
confirmButton = {
TextButton(onClick = { onSelect(selectedValue) }) {
Text(stringResource(R.string.message_box_accept))
}
}
) {
CompositionLocalProvider(
LocalSelectValue provides (selectedValue as Any),
LocalSelectSetValue provides ({
@Suppress("UNCHECKED_CAST")
setSelectedValue(it as T)
})
) {
options()
}
}
}
@@ -48,8 +48,19 @@ data class LoaderPayload<T>(
data class DownloadableAsset(val url: String, val filename: String, val size: Long? = null) data class DownloadableAsset(val url: String, val filename: String, val size: Long? = null)
abstract class Downloadable { abstract class Downloadable {
/**
* Human-readable representation of the current version.
*/
abstract fun getDescription(): String abstract fun getDescription(): String
/**
* Unique value that refers to the current version, used for comparison.
*/
abstract fun getDescriptor(): Long abstract fun getDescriptor(): Long
/**
* Release download link for the current platform
*/
abstract fun getDownload(): DownloadableAsset? abstract fun getDownload(): DownloadableAsset?
} }
@@ -19,8 +19,10 @@ import kotlinx.coroutines.launch
import okhttp3.Cache import okhttp3.Cache
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okio.buffer import okio.buffer
import okio.sink
import okio.source import okio.source
import java.io.File import java.io.File
import java.io.FileNotFoundException
import java.io.InterruptedIOException import java.io.InterruptedIOException
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
@@ -84,8 +86,13 @@ 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<DownloadableLauncherRelease?>(null) private val _availableLauncherUpdateTag = MutableStateFlow<String?>(null)
val availableLauncherUpdate = _availableLauncherUpdate.asStateFlow() val availableLauncherUpdateTag = _availableLauncherUpdateTag.asStateFlow()
private val _availableLauncherUpdateDetails = MutableStateFlow<DownloadableLauncherRelease?>(null)
val availableLauncherUpdateDetails = _availableLauncherUpdateDetails.asStateFlow()
var dismissedLauncherUpdate = false
private fun sendError(e: Exception) { private fun sendError(e: Exception) {
_uiState.value = ReleaseManagerState.Failure(e) _uiState.value = ReleaseManagerState.Failure(e)
@@ -133,32 +140,30 @@ class ReleaseManager private constructor(
} }
} }
private suspend fun downloadLauncherUpdate(release: Downloadable) { private suspend fun downloadLauncherUpdate(release: Downloadable): Result<File> {
val download = release.getDownload() ?: return val download = release.getDownload()
?: return Result.failure(FileNotFoundException())
val outputDirectory = applicationContext.filesDir val outputDirectory = applicationContext.filesDir
val outputFile = File(outputDirectory, download.filename) val outputFile = File(outputDirectory, download.filename)
if (outputFile.exists()) {
// only download the apk once
return
}
_uiState.value = ReleaseManagerState.InDownload(0, download.size)
try { try {
val fileStream = DownloadUtils.downloadStream( DownloadUtils.downloadStream(
httpClient, httpClient,
download.url download.url,
) { progress, outOf -> onResponse = { body ->
_uiState.value = ReleaseManagerState.InDownload(progress, outOf) body.source().use { source ->
} outputFile.sink().buffer().use { sink ->
sink.writeAll(source)
fileStream.copyTo(outputFile.outputStream()) }
}
}
)
} catch (e: Exception) { } catch (e: Exception) {
sendError(e) return Result.failure(e)
return
} }
return Result.success(outputFile)
} }
private suspend fun performUpdate(release: Downloadable) { private suspend fun performUpdate(release: Downloadable) {
@@ -177,44 +182,60 @@ class ReleaseManager private constructor(
// clone the file instance as renameTo may move the original file // clone the file instance as renameTo may move the original file
val tempFilePath = outputFile.path val tempFilePath = outputFile.path
val geodeFile = getGeodeOutputPath()
try { try {
val fileStream = DownloadUtils.downloadStream( // tempFile should be in same path as geodeFile
val geodeParent = geodeFile.parentFile
if (geodeParent != null && !geodeParent.exists()) {
geodeParent.mkdirs()
}
DownloadUtils.downloadStream(
httpClient, httpClient,
releaseAsset.url releaseAsset.url,
) { progress, outOf -> onProgress = { progress, outOf ->
_uiState.value = ReleaseManagerState.InDownload(progress, outOf) _uiState.value = ReleaseManagerState.InDownload(progress, outOf)
} },
onResponse = { body ->
DownloadUtils.extractFileFromZipStream(
body.byteStream(),
outputFile,
geodeFile.name
)
outputFile.parentFile?.mkdirs() // work around a permission issue from adb push
if (geodeFile.exists()) {
geodeFile.delete()
}
val geodeFile = getGeodeOutputPath() val renameSuccessful = runCatching {
outputFile.renameTo(geodeFile)
}.getOrDefault(false)
DownloadUtils.extractFileFromZipStream( if (!renameSuccessful) {
fileStream, // attempt a manual copy if rename fails for whatever reason
outputFile.outputStream(), DownloadUtils.copyFile(outputFile, geodeFile)
geodeFile.name }
}
) )
// work around a permission issue from adb push
if (geodeFile.exists()) {
geodeFile.delete()
}
outputFile.renameTo(geodeFile)
} catch (e: Exception) { } catch (e: Exception) {
sendError(e) sendError(e)
return return
} finally { } finally {
val tempFileClone = File(tempFilePath) val tempFileClone = File(tempFilePath)
try { runCatching {
if (tempFileClone.exists()) tempFileClone.delete() if (tempFileClone.exists()) tempFileClone.delete()
} catch (_: Exception) { } }
} }
downloadLauncherUpdateIfNecessary()
// extraction performed // extraction performed
updatePreferences(release) updatePreferences(release)
val currentTime = Clock.System.now().toEpochMilliseconds()
PreferenceUtils.get(applicationContext)
.setLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME, currentTime)
_uiState.value = ReleaseManagerState.Finished(true) _uiState.value = ReleaseManagerState.Finished(true)
} }
@@ -235,25 +256,71 @@ class ReleaseManager private constructor(
return originalFileHash != currentFileHash return originalFileHash != currentFileHash
} }
private fun checkLauncherUpdate(launcherUpdate: DownloadableLauncherRelease) { private suspend fun getLatestLauncherUpdate(): DownloadableLauncherRelease? {
if (launcherUpdate.release.tagName != BuildConfig.VERSION_NAME) { return try {
_availableLauncherUpdate.value = launcherUpdate releaseRepository.getLatestLauncherRelease()
} catch (e: Exception) {
sendError(e)
return null
} }
} }
private suspend fun downloadLauncherUpdateIfNecessary() { suspend fun checkLauncherUpdate() {
val update = _availableLauncherUpdate.value ?: return val launcherRelease = getLatestLauncherUpdate() ?: return
downloadLauncherUpdate(update)
// unconditionally track it, we're not using it for checks and it might be good to have
_availableLauncherUpdateDetails.value = launcherRelease
val launcherTag = launcherRelease.release.tagName
PreferenceUtils.get(applicationContext)
.setString(PreferenceUtils.Key.LAST_LAUNCHER_UPDATE, launcherTag)
if (launcherTag != BuildConfig.VERSION_NAME) {
_availableLauncherUpdateTag.value = launcherTag
}
} }
private suspend fun checkForNewRelease(isManual: Boolean = false) { fun checkCachedLauncherUpdate() {
val lastUpdate = PreferenceUtils.get(applicationContext)
.getString(PreferenceUtils.Key.LAST_LAUNCHER_UPDATE) ?: return
if (lastUpdate != BuildConfig.VERSION_NAME) {
_availableLauncherUpdateTag.value = lastUpdate
}
}
suspend fun downloadLatestLauncherUpdate(): Result<File> {
val update = _availableLauncherUpdateDetails.value
?: getLatestLauncherUpdate()
?: return Result.failure(FileNotFoundException())
return downloadLauncherUpdate(update)
}
fun shouldUseCache(): Boolean {
val sharedPreferences = PreferenceUtils.get(applicationContext) val sharedPreferences = PreferenceUtils.get(applicationContext)
if (sharedPreferences.getBoolean(PreferenceUtils.Key.DISABLE_UPDATE_CACHE)) {
return false
}
val lastCheckTime = sharedPreferences.getLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME) val lastCheckTime = sharedPreferences.getLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME)
// only check for updates if it's been over 15 minutes since last check // only check for updates if it's been over 15 minutes since last check
val checkMinTime = Clock.System.now().minus(15.minutes).toEpochMilliseconds() val checkMinTime = Clock.System.now().minus(15.minutes).toEpochMilliseconds()
if (!isManual && lastCheckTime > checkMinTime && !fileWasExternallyModified()) { return lastCheckTime > checkMinTime && !fileWasExternallyModified()
_uiState.value = ReleaseManagerState.Finished() }
fun afterUseCachedUpdate() {
checkCachedLauncherUpdate()
_uiState.tryEmit(ReleaseManagerState.Finished())
}
private suspend fun checkForNewRelease(isManual: Boolean = false) {
val sharedPreferences = PreferenceUtils.get(applicationContext)
if (!isManual && shouldUseCache()) {
afterUseCachedUpdate()
return return
} }
@@ -264,22 +331,11 @@ class ReleaseManager private constructor(
return return
} }
val launcherRelease = try { checkLauncherUpdate()
releaseRepository.getLatestLauncherRelease()
} catch (e: Exception) {
sendError(e)
return
}
if (launcherRelease != null) {
checkLauncherUpdate(launcherRelease)
}
val currentTime = Clock.System.now().toEpochMilliseconds()
sharedPreferences.setLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME, currentTime)
if (release == null) { if (release == null) {
downloadLauncherUpdateIfNecessary() val currentTime = Clock.System.now().toEpochMilliseconds()
sharedPreferences.setLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME, currentTime)
_uiState.value = ReleaseManagerState.Finished() _uiState.value = ReleaseManagerState.Finished()
return return
@@ -290,7 +346,8 @@ class ReleaseManager private constructor(
// check if an update is needed // check if an update is needed
if (latestVersion == currentVersion && !fileWasExternallyModified()) { if (latestVersion == currentVersion && !fileWasExternallyModified()) {
downloadLauncherUpdateIfNecessary() val currentTime = Clock.System.now().toEpochMilliseconds()
sharedPreferences.setLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME, currentTime)
_uiState.value = ReleaseManagerState.Finished() _uiState.value = ReleaseManagerState.Finished()
return return
@@ -300,8 +357,6 @@ class ReleaseManager private constructor(
// check if the file was externally modified // check if the file was externally modified
if (!allowOverwriting && fileWasExternallyModified(true)) { if (!allowOverwriting && fileWasExternallyModified(true)) {
downloadLauncherUpdateIfNecessary()
sendError(UpdateException(UpdateException.Reason.EXTERNAL_FILE_IN_USE)) sendError(UpdateException(UpdateException.Reason.EXTERNAL_FILE_IN_USE))
return return
} }
@@ -330,7 +385,7 @@ class ReleaseManager private constructor(
} }
private fun computeFileHash(file: File): String = private fun computeFileHash(file: File): String =
file.source().buffer().readByteString().md5().hex() file.source().buffer().use { it.readByteString().md5().hex() }
private fun getGeodeOutputPath(): File { private fun getGeodeOutputPath(): File {
val geodeName = LaunchUtils.geodeFilename val geodeName = LaunchUtils.geodeFilename
@@ -343,7 +398,17 @@ class ReleaseManager private constructor(
val geodeName = LaunchUtils.geodeFilename val geodeName = LaunchUtils.geodeFilename
val geodeDirectory = LaunchUtils.getBaseDirectory(applicationContext) val geodeDirectory = LaunchUtils.getBaseDirectory(applicationContext)
val tempFile = File.createTempFile(geodeName, null, geodeDirectory) // warning!! while File::createTempFile may look tempting, a certain brand of phones has a messed up implementation of it
// so we're making a temp file manually (as long as it doesn't collide with the geode download, it's okay)
val alphabet = ('A'..'Z') + ('a'..'z') + ('0'..'9')
val suffix = buildString(8) {
repeat(8) { append(alphabet.random()) }
}
val tmpName = "tmp-$suffix.$geodeName"
val tempFile = File(geodeDirectory, tmpName)
return tempFile return tempFile
} }
@@ -365,9 +430,13 @@ class ReleaseManager private constructor(
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
fun checkForUpdates(isManual: Boolean = false): StateFlow<ReleaseManagerState> { fun checkForUpdates(isManual: Boolean = false): StateFlow<ReleaseManagerState> {
if (!isInUpdate) { if (!isInUpdate) {
_uiState.value = ReleaseManagerState.InUpdateCheck if (isManual || !shouldUseCache()) {
updateJob = GlobalScope.launch { _uiState.value = ReleaseManagerState.InUpdateCheck
checkForNewRelease(isManual) updateJob = GlobalScope.launch {
checkForNewRelease(isManual)
}
} else {
afterUseCachedUpdate()
} }
} }
@@ -1,6 +1,7 @@
package com.geode.launcher.updater package com.geode.launcher.updater
import com.geode.launcher.utils.DownloadUtils.executeCoroutine import com.geode.launcher.BuildConfig
import com.geode.launcher.utils.LaunchUtils
import kotlin.time.Clock import kotlin.time.Clock
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlin.time.Instant import kotlin.time.Instant
@@ -10,8 +11,10 @@ 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.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttp
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.coroutines.executeAsync
import java.io.IOException import java.io.IOException
import java.net.URL import java.net.URL
import kotlin.time.ExperimentalTime import kotlin.time.ExperimentalTime
@@ -28,6 +31,8 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
private const val GEODE_API_BASE = "https://api.geode-sdk.org/v1" private const val GEODE_API_BASE = "https://api.geode-sdk.org/v1"
} }
private val userAgent = "okhttp/${OkHttp.VERSION} (launcher=${BuildConfig.VERSION_NAME};arch=${LaunchUtils.applicationArchitecture})"
suspend fun getLatestLauncherRelease(): DownloadableLauncherRelease? { 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"
@@ -59,48 +64,50 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
.url(url) .url(url)
.addHeader("Accept", "application/json") .addHeader("Accept", "application/json")
.addHeader(GITHUB_API_HEADER, GITHUB_API_VERSION) .addHeader(GITHUB_API_HEADER, GITHUB_API_VERSION)
.addHeader("User-Agent", userAgent)
.build() .build()
val call = httpClient.newCall(request) val call = httpClient.newCall(request)
val response = call.executeCoroutine()
return when (response.code) { return call.executeAsync().use { response ->
200 -> { when (response.code) {
val format = Json { 200 -> {
namingStrategy = JsonNamingStrategy.SnakeCase val format = Json {
ignoreUnknownKeys = true namingStrategy = JsonNamingStrategy.SnakeCase
ignoreUnknownKeys = true
}
val release = format.decodeFromBufferedSource<List<Release>>(
response.body.source()
)
(release.find { it.tagName != "nightly" })
?.let(::DownloadableGitHubLoaderRelease)
} }
403 -> {
// determine if the error code is a ratelimit
// (github docs say it sends 429 too, but haven't seen that)
val release = format.decodeFromBufferedSource<List<Release>>( val limitRemaining = response.headers.get(GITHUB_RATELIMIT_REMAINING)?.toInt()
response.body!!.source() val limitReset = response.headers.get(GITHUB_RATELIMIT_RESET)?.toLong()
)
(release.find { it.tagName != "nightly" }) if (limitRemaining == 0 && limitReset != null) {
?.let(::DownloadableGitHubLoaderRelease) // handle ratelimit with a custom error
} // there's also a retry-after header but again, haven't seen
403 -> { val resetTime = Instant.fromEpochSeconds(limitReset, 0L)
// determine if the error code is a ratelimit val msg = generateRateLimitMessage(resetTime)
// (github docs say it sends 429 too, but haven't seen that)
val limitRemaining = response.headers.get(GITHUB_RATELIMIT_REMAINING)?.toInt() throw IOException(msg)
val limitReset = response.headers.get(GITHUB_RATELIMIT_RESET)?.toLong() }
if (limitRemaining == 0 && limitReset != null) { throw IOException("response 403: ${response.body.string()}")
// handle ratelimit with a custom error }
// there's also a retry-after header but again, haven't seen 404 -> {
val resetTime = Instant.fromEpochSeconds(limitReset, 0L) null
val msg = generateRateLimitMessage(resetTime) }
else -> {
throw IOException(msg) throw IOException("unknown response ${response.code}")
} }
throw IOException("response 403: ${response.body!!.string()}")
}
404 -> {
null
}
else -> {
throw IOException("unknown response ${response.code}")
} }
} }
} }
@@ -121,47 +128,48 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
.url(url) .url(url)
.addHeader("Accept", "application/json") .addHeader("Accept", "application/json")
.addHeader(GITHUB_API_HEADER, GITHUB_API_VERSION) .addHeader(GITHUB_API_HEADER, GITHUB_API_VERSION)
.addHeader("User-Agent", userAgent)
.build() .build()
val call = httpClient.newCall(request) val call = httpClient.newCall(request)
val response = call.executeCoroutine() return call.executeAsync().use { response ->
when (response.code) {
200 -> {
val format = Json {
namingStrategy = JsonNamingStrategy.SnakeCase
ignoreUnknownKeys = true
}
return when (response.code) { val release = format.decodeFromBufferedSource<Release>(
200 -> { response.body.source()
val format = Json { )
namingStrategy = JsonNamingStrategy.SnakeCase
ignoreUnknownKeys = true release
} }
403 -> {
// determine if the error code is a ratelimit
// (github docs say it sends 429 too, but haven't seen that)
val release = format.decodeFromBufferedSource<Release>( val limitRemaining = response.headers[GITHUB_RATELIMIT_REMAINING]?.toInt()
response.body!!.source() val limitReset = response.headers[GITHUB_RATELIMIT_RESET]?.toLong()
)
release if (limitRemaining == 0 && limitReset != null) {
} // handle ratelimit with a custom error
403 -> { // there's also a retry-after header but again, haven't seen
// determine if the error code is a ratelimit val resetTime = Instant.fromEpochSeconds(limitReset, 0L)
// (github docs say it sends 429 too, but haven't seen that) val msg = generateRateLimitMessage(resetTime)
val limitRemaining = response.headers[GITHUB_RATELIMIT_REMAINING]?.toInt() throw IOException(msg)
val limitReset = response.headers[GITHUB_RATELIMIT_RESET]?.toLong() }
if (limitRemaining == 0 && limitReset != null) { throw IOException("response 403: ${response.body.string()}")
// handle ratelimit with a custom error }
// there's also a retry-after header but again, haven't seen 404 -> {
val resetTime = Instant.fromEpochSeconds(limitReset, 0L) null
val msg = generateRateLimitMessage(resetTime) }
else -> {
throw IOException(msg) throw IOException("unknown response ${response.code}")
} }
throw IOException("response 403: ${response.body!!.string()}")
}
404 -> {
null
}
else -> {
throw IOException("unknown response ${response.code}")
} }
} }
} }
@@ -178,31 +186,32 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
val request = Request.Builder() val request = Request.Builder()
.url(url) .url(url)
.addHeader("Accept", "application/json") .addHeader("Accept", "application/json")
.addHeader("User-Agent", userAgent)
.build() .build()
val call = httpClient.newCall(request) val call = httpClient.newCall(request)
val response = call.executeCoroutine() return call.executeAsync().use { response ->
when (response.code) {
200 -> {
val format = Json {
namingStrategy = JsonNamingStrategy.SnakeCase
ignoreUnknownKeys = true
}
return when (response.code) { val body = format.decodeFromBufferedSource<LoaderPayload<LoaderVersion>>(
200 -> { response.body.source()
val format = Json { )
namingStrategy = JsonNamingStrategy.SnakeCase
ignoreUnknownKeys = true val release = body.payload ?: throw IOException("index error: ${body.error}")
DownloadableLoaderRelease(release)
}
404 -> {
null
}
else -> {
throw IOException("unknown response ${response.code}")
} }
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}")
} }
} }
} }
@@ -53,7 +53,7 @@ class ReleaseViewModel(private val application: Application): ViewModel() {
val isInUpdate val isInUpdate
get() = ReleaseManager.get(application).isInUpdate get() = ReleaseManager.get(application).isInUpdate
val nextLauncherUpdate = ReleaseManager.get(application).availableLauncherUpdate val nextLauncherUpdate = ReleaseManager.get(application).availableLauncherUpdateTag
var hasPerformedCheck = false var hasPerformedCheck = false
private set private set
@@ -20,7 +20,7 @@ object Constants {
const val SUPPORTED_VERSION_CODE_MIN_WARNING = 40L const val SUPPORTED_VERSION_CODE_MIN_WARNING = 40L
// anything above this version code will show a warning when starting the game // anything above this version code will show a warning when starting the game
const val SUPPORTED_VERSION_CODE = 40L const val SUPPORTED_VERSION_CODE = 41L
const val SUPPORTED_VERSION_STRING = "2.2.143" const val SUPPORTED_VERSION_STRING = "2.2.144"
} }
@@ -2,27 +2,31 @@ package com.geode.launcher.utils
import android.os.Build import android.os.Build
import android.os.FileUtils import android.os.FileUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext
import okhttp3.Call
import okhttp3.Callback
import okhttp3.MediaType import okhttp3.MediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.RequestBody
import okhttp3.ResponseBody import okhttp3.ResponseBody
import okhttp3.coroutines.executeAsync
import okio.Buffer import okio.Buffer
import okio.BufferedSink
import okio.BufferedSource import okio.BufferedSource
import okio.FileSystem
import okio.ForwardingSink
import okio.ForwardingSource import okio.ForwardingSource
import okio.Path.Companion.toOkioPath
import okio.Sink
import okio.Source import okio.Source
import okio.buffer import okio.buffer
import java.io.File
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.zip.ZipInputStream import java.util.zip.ZipInputStream
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
typealias ProgressCallback = (progress: Long, outOf: Long) -> Unit typealias ProgressCallback = (progress: Long, outOf: Long) -> Unit
@@ -31,8 +35,9 @@ object DownloadUtils {
suspend fun downloadStream( suspend fun downloadStream(
httpClient: OkHttpClient, httpClient: OkHttpClient,
url: String, url: String,
onProgress: ProgressCallback? = null onProgress: ProgressCallback? = null,
): InputStream { onResponse: suspend (ResponseBody) -> Unit,
) {
val request = Request.Builder() val request = Request.Builder()
.url(url) .url(url)
.build() .build()
@@ -50,7 +55,7 @@ object DownloadUtils {
val originalResponse = chain.proceed(chain.request()) val originalResponse = chain.proceed(chain.request())
originalResponse.newBuilder() originalResponse.newBuilder()
.body(ProgressResponseBody( .body(ProgressResponseBody(
originalResponse.body!!, onProgress originalResponse.body, onProgress
)) ))
.build() .build()
} }
@@ -59,50 +64,35 @@ object DownloadUtils {
val progressClient = progressClientBuilder.build() val progressClient = progressClientBuilder.build()
val call = progressClient.newCall(request) val call = progressClient.newCall(request)
val response = call.executeCoroutine() call.executeAsync().use { response ->
when (response.code) {
return response.body!!.byteStream() 200 -> onResponse(response.body)
} else -> throw IOException("unexpected response ${response.code}")
suspend fun Call.executeCoroutine(): Response {
return suspendCancellableCoroutine { continuation ->
this.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
if (continuation.isCancelled) {
return
}
continuation.resumeWithException(e)
}
override fun onResponse(call: Call, response: Response) {
continuation.resume(response)
}
})
continuation.invokeOnCancellation {
this.cancel()
} }
} }
} }
suspend fun extractFileFromZipStream(inputStream: InputStream, outputStream: OutputStream, zipPath: String) { /**
* Extracts a file named by zipPath from inputStream and copies it to outputStream
*
* inputStream and outputStream are both closed after this function executes
*/
suspend fun extractFileFromZipStream(inputStream: InputStream, output: File, zipPath: String) {
// note to self: ZipInputStreams are a little silly // note to self: ZipInputStreams are a little silly
// (runInterruptible allows it to cancel, otherwise it waits for the stream to finish) // (runInterruptible allows it to cancel, otherwise it waits for the stream to finish)
runInterruptible { runInterruptible {
inputStream.use { inputStream.use { input ->
outputStream.use { output.outputStream().use { out ->
// nice indentation // nice indentation
val zip = ZipInputStream(inputStream) ZipInputStream(input).use { zip ->
zip.use { var entry = zip.nextEntry
var entry = it.nextEntry
while (entry != null) { while (entry != null) {
if (entry.name == zipPath) { if (entry.name == zipPath) {
it.copyTo(outputStream) zip.copyTo(out)
return@runInterruptible return@runInterruptible
} }
entry = it.nextEntry entry = zip.nextEntry
} }
// no matching entries found, throw exception // no matching entries found, throw exception
@@ -113,6 +103,22 @@ object DownloadUtils {
} }
} }
suspend fun copyFile(from: File, to: File) {
val fs = FileSystem.SYSTEM
val fromPath = from.toOkioPath()
val toPath = to.toOkioPath()
withContext(Dispatchers.IO) {
fs.copy(fromPath, toPath)
}
}
/**
* Copies data from inputStream to outputStream
*
* Both streams are closed upon the conclusion of this function.
*/
fun copyFile(inputStream: InputStream, outputStream: OutputStream) { fun copyFile(inputStream: InputStream, outputStream: OutputStream) {
// gotta love copying // gotta love copying
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@@ -166,4 +172,39 @@ private class ProgressResponseBody (
} }
} }
} }
}
// now based on <https://github.com/square/okhttp/blob/master/samples/guide/src/main/java/okhttp3/recipes/UploadProgress.java#L84>
class ProgressRequestBody(
private val requestBody: RequestBody,
private val progressCallback: ProgressCallback
) : RequestBody() {
override fun contentType(): MediaType? {
return requestBody.contentType()
}
override fun contentLength(): Long {
return requestBody.contentLength()
}
override fun writeTo(sink: BufferedSink) {
val bufferedSink = this.sink(sink).buffer()
requestBody.writeTo(bufferedSink)
bufferedSink.flush()
}
private fun sink(sink: Sink): Sink {
return object : ForwardingSink(sink) {
var totalBytesWritten = 0L
override fun write(source: Buffer, byteCount: Long) {
super.write(source, byteCount)
totalBytesWritten += byteCount
progressCallback(
totalBytesWritten,
contentLength()
)
}
}
}
} }
@@ -69,8 +69,15 @@ object GamePackageUtils {
packageInfo.applicationInfo!!.flags and ApplicationInfo.FLAG_EXTRACT_NATIVE_LIBS == ApplicationInfo.FLAG_EXTRACT_NATIVE_LIBS packageInfo.applicationInfo!!.flags and ApplicationInfo.FLAG_EXTRACT_NATIVE_LIBS == ApplicationInfo.FLAG_EXTRACT_NATIVE_LIBS
val isSplit = (packageInfo.applicationInfo?.splitSourceDirs?.size ?: 0) > 1 val isSplit = (packageInfo.applicationInfo?.splitSourceDirs?.size ?: 0) > 1
val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
packageInfo.longVersionCode
} else {
@Suppress("DEPRECATION")
packageInfo.versionCode.toLong()
}
val metadata = val metadata =
"Geometry Dash metadata:\nSplit sources: $isSplit\nExtracted libraries: $isExtracted\nLauncher architecture: $abi" "Geometry Dash metadata:\nSplit sources: $isSplit\nExtracted libraries: $isExtracted\nLauncher architecture: $abi\nGame version: $versionCode"
Log.i("GeodeLauncher", metadata) Log.i("GeodeLauncher", metadata)
// easiest check! these messages are hardcoded in bionic/linker/linker_phdr.cpp // easiest check! these messages are hardcoded in bionic/linker/linker_phdr.cpp
@@ -10,6 +10,7 @@ import android.content.pm.PackageManager
import android.hardware.lights.Light import android.hardware.lights.Light
import android.hardware.lights.LightState import android.hardware.lights.LightState
import android.hardware.lights.LightsRequest import android.hardware.lights.LightsRequest
import android.net.ConnectivityManager
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment import android.os.Environment
@@ -207,24 +208,20 @@ object GeodeUtils {
val context = activity.get()!! val context = activity.get()!!
val pathFile = File(path) val pathFile = File(path)
val baseDirectory = LaunchUtils.getBaseDirectory(context) val baseDirectory = LaunchUtils.getBaseDirectory(context, true)
val isInternalPath = pathFile.startsWith(baseDirectory) val isInternalPath = pathFile.startsWith(baseDirectory)
val intent = if (isInternalPath) { val intent = if (isInternalPath) {
// TODO: figure out how to get this to point to the path it should be pointing at val relativePath = pathFile.relativeTo(baseDirectory)
// (the best i got was pointing at a file) val uri = DocumentsContract.buildDocumentUri(
// val relativePath = pathFile.relativeTo(baseDirectory) "com.android.externalstorage.documents",
"primary:Android/media/${context.packageName}/$relativePath"
)
Intent(Intent.ACTION_VIEW).apply { Intent(Intent.ACTION_VIEW).apply {
data = DocumentsContract.buildRootUri( setDataAndType(uri, DocumentsContract.Document.MIME_TYPE_DIR)
"${context.packageName}.user", UserDirectoryProvider.ROOT
)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
Intent.FLAG_GRANT_PREFIX_URI_PERMISSION or
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
)
} }
} else { } else {
Intent(Intent.ACTION_OPEN_DOCUMENT).apply { Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
@@ -394,6 +391,15 @@ object GeodeUtils {
return "" return ""
} }
@JvmStatic
fun getGameVersionCode(): Long {
activity.get()?.run {
return GamePackageUtils.getGameVersionCode(packageManager)
}
return 0L
}
/** /**
* Returns the current version code of the launcher. * Returns the current version code of the launcher.
*/ */
@@ -762,6 +768,11 @@ object GeodeUtils {
return true return true
} }
@JvmStatic
fun getConnectivityManager(): ConnectivityManager? = activity.get()?.run {
getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
}
external fun nativeKeyUp(keyCode: Int, modifiers: Int) external fun nativeKeyUp(keyCode: Int, modifiers: Int)
external fun nativeKeyDown(keyCode: Int, modifiers: Int, isRepeating: Boolean) external fun nativeKeyDown(keyCode: Int, modifiers: Int, isRepeating: Boolean)
external fun nativeActionScroll(scrollX: Float, scrollY: Float) external fun nativeActionScroll(scrollX: Float, scrollY: Float)
@@ -80,6 +80,11 @@ object LaunchUtils {
return File(base, "game/geode/crashlogs/") return File(base, "game/geode/crashlogs/")
} }
fun getGeodeLogsDirectory(context: Context): File {
val base = getBaseDirectory(context)
return File(base, "game/geode/logs/")
}
fun getLastCrash(context: Context): File? { fun getLastCrash(context: Context): File? {
val crashDirectory = getCrashDirectory(context) val crashDirectory = getCrashDirectory(context)
if (!crashDirectory.exists()) { if (!crashDirectory.exists()) {
@@ -114,6 +119,12 @@ object LaunchUtils {
} }
} }
data class FullLauncherError(
val error: LauncherError,
val returnMessage: String,
val extendedMessage: String? = null,
) : Serializable
const val LAUNCHER_KEY_RETURN_ERROR = "return_error" const val LAUNCHER_KEY_RETURN_ERROR = "return_error"
const val LAUNCHER_KEY_RETURN_MESSAGE = "return_message" const val LAUNCHER_KEY_RETURN_MESSAGE = "return_message"
const val LAUNCHER_KEY_RETURN_EXTENDED_MESSAGE = "return_extended_message" const val LAUNCHER_KEY_RETURN_EXTENDED_MESSAGE = "return_extended_message"
@@ -127,36 +127,33 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
enum class Key { enum class Key {
LOAD_AUTOMATICALLY, LOAD_AUTOMATICALLY,
UPDATE_AUTOMATICALLY, UPDATE_AUTOMATICALLY,
RELEASE_CHANNEL,
CURRENT_VERSION_TAG, CURRENT_VERSION_TAG,
CURRENT_VERSION_TIMESTAMP, CURRENT_VERSION_TIMESTAMP,
THEME, THEME,
BLACK_BACKGROUND, BLACK_BACKGROUND,
CURRENT_RELEASE_MODIFIED, CURRENT_RELEASE_MODIFIED,
LAST_DISMISSED_UPDATE,
DISMISSED_GJ_UPDATE, DISMISSED_GJ_UPDATE,
LAUNCH_ARGUMENTS, LAUNCH_ARGUMENTS,
LIMIT_ASPECT_RATIO, LIMIT_ASPECT_RATIO,
DISPLAY_MODE, DISPLAY_MODE,
FORCE_HRR,
RELEASE_CHANNEL_TAG, RELEASE_CHANNEL_TAG,
DEVELOPER_MODE, DEVELOPER_MODE,
CLEANUP_APKS, CLEANUP_APKS,
CUSTOM_SYMBOL_LIST,
CUSTOM_ASPECT_RATIO, CUSTOM_ASPECT_RATIO,
SCREEN_ZOOM, SCREEN_ZOOM,
SCREEN_ZOOM_FIT, SCREEN_ZOOM_FIT,
LIMIT_FRAME_RATE, LIMIT_FRAME_RATE,
DISABLE_USER_THEME, DISABLE_USER_THEME,
SELECTED_ICON, SELECTED_ICON,
DISABLE_REDESIGN,
WAIT_PERIOD, WAIT_PERIOD,
LAST_UPDATE_CHECK_TIME LAST_UPDATE_CHECK_TIME,
LAST_LAUNCHER_UPDATE,
DISABLE_UPDATE_CACHE,
} }
private fun defaultValueForBooleanKey(key: Key): Boolean { private fun defaultValueForBooleanKey(key: Key): Boolean {
return when (key) { return when (key) {
Key.UPDATE_AUTOMATICALLY, Key.FORCE_HRR -> true Key.UPDATE_AUTOMATICALLY -> true
Key.DEVELOPER_MODE -> BuildConfig.DEBUG Key.DEVELOPER_MODE -> BuildConfig.DEBUG
else -> false else -> false
} }
@@ -180,23 +177,18 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
return when (key) { return when (key) {
Key.LOAD_AUTOMATICALLY -> "PreferenceLoadAutomatically" Key.LOAD_AUTOMATICALLY -> "PreferenceLoadAutomatically"
Key.UPDATE_AUTOMATICALLY -> "PreferenceUpdateAutomatically" Key.UPDATE_AUTOMATICALLY -> "PreferenceUpdateAutomatically"
Key.RELEASE_CHANNEL -> "PreferenceReleaseChannel"
Key.CURRENT_VERSION_TAG -> "PreferenceCurrentVersionName" Key.CURRENT_VERSION_TAG -> "PreferenceCurrentVersionName"
Key.CURRENT_VERSION_TIMESTAMP -> "PreferenceCurrentVersionDescriptor" Key.CURRENT_VERSION_TIMESTAMP -> "PreferenceCurrentVersionDescriptor"
Key.THEME -> "PreferenceTheme" Key.THEME -> "PreferenceTheme"
Key.BLACK_BACKGROUND -> "PreferenceBlackBackground" Key.BLACK_BACKGROUND -> "PreferenceBlackBackground"
Key.CURRENT_RELEASE_MODIFIED -> "PreferenceReleaseModifiedHash" Key.CURRENT_RELEASE_MODIFIED -> "PreferenceReleaseModifiedHash"
Key.LAST_DISMISSED_UPDATE -> "PreferenceLastDismissedUpdate"
Key.DISMISSED_GJ_UPDATE -> "PreferenceDismissedGJUpdate" Key.DISMISSED_GJ_UPDATE -> "PreferenceDismissedGJUpdate"
Key.LAUNCH_ARGUMENTS -> "PreferenceLaunchArguments" Key.LAUNCH_ARGUMENTS -> "PreferenceLaunchArguments"
Key.LIMIT_ASPECT_RATIO -> "PreferenceLimitAspectRatio" Key.LIMIT_ASPECT_RATIO -> "PreferenceLimitAspectRatio"
Key.DISPLAY_MODE -> "PreferenceDisplayMode" Key.DISPLAY_MODE -> "PreferenceDisplayMode"
Key.FORCE_HRR -> "PreferenceForceHighRefreshRate"
Key.DISABLE_REDESIGN -> "PreferenceDisableRedesign"
Key.RELEASE_CHANNEL_TAG -> "PreferenceReleaseChannelTag" Key.RELEASE_CHANNEL_TAG -> "PreferenceReleaseChannelTag"
Key.DEVELOPER_MODE -> "PreferenceDeveloperMode" Key.DEVELOPER_MODE -> "PreferenceDeveloperMode"
Key.CLEANUP_APKS -> "PreferenceCleanupPackages" Key.CLEANUP_APKS -> "PreferenceCleanupPackages"
Key.CUSTOM_SYMBOL_LIST -> "PreferenceCustomSymbolList"
Key.CUSTOM_ASPECT_RATIO -> "PreferenceCustomAspectRatio" Key.CUSTOM_ASPECT_RATIO -> "PreferenceCustomAspectRatio"
Key.SCREEN_ZOOM -> "PreferenceScreenZoom" Key.SCREEN_ZOOM -> "PreferenceScreenZoom"
Key.SCREEN_ZOOM_FIT -> "PreferenceScreenZoomFit" Key.SCREEN_ZOOM_FIT -> "PreferenceScreenZoomFit"
@@ -205,6 +197,13 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
Key.SELECTED_ICON -> "PreferenceSelectedIcon" Key.SELECTED_ICON -> "PreferenceSelectedIcon"
Key.WAIT_PERIOD -> "PreferenceWaitPeriod" Key.WAIT_PERIOD -> "PreferenceWaitPeriod"
Key.LAST_UPDATE_CHECK_TIME -> "PreferenceLastUpdateCheckTime" Key.LAST_UPDATE_CHECK_TIME -> "PreferenceLastUpdateCheckTime"
Key.LAST_LAUNCHER_UPDATE -> "PreferenceLastLauncherUpdate"
Key.DISABLE_UPDATE_CACHE -> "PreferenceDisableUpdateCache"
// Key.RELEASE_CHANNEL -> "PreferenceReleaseChannel"
// Key.LAST_DISMISSED_UPDATE -> "PreferenceLastDismissedUpdate"
// Key.FORCE_HRR -> "PreferenceForceHighRefreshRate"
// Key.DISABLE_REDESIGN -> "PreferenceDisableRedesign"
// Key.CUSTOM_SYMBOL_LIST -> "PreferenceCustomSymbolList"
} }
} }
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M260,800Q169,800 104.5,737Q40,674 40,583Q40,505 87,444Q134,383 210,366Q235,274 310,217Q385,160 480,160Q597,160 678.5,241.5Q760,323 760,440L760,440L760,440Q829,448 874.5,499.5Q920,551 920,620Q920,695 867.5,747.5Q815,800 740,800L260,800ZM260,720L740,720Q782,720 811,691Q840,662 840,620Q840,578 811,549Q782,520 740,520L680,520L680,440Q680,357 621.5,298.5Q563,240 480,240Q397,240 338.5,298.5Q280,357 280,440L280,440L260,440Q202,440 161,481Q120,522 120,580Q120,638 161,679Q202,720 260,720ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480ZM480,640Q497,640 508.5,628.5Q520,617 520,600Q520,583 508.5,571.5Q497,560 480,560Q463,560 451.5,571.5Q440,583 440,600Q440,617 451.5,628.5Q463,640 480,640ZM440,500L520,500L520,320L440,320L440,500Z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M260,800Q169,800 104.5,737Q40,674 40,583Q40,505 87,444Q134,383 210,366Q235,274 310,217Q385,160 480,160Q597,160 678.5,241.5Q760,323 760,440L760,440L760,440Q829,448 874.5,499.5Q920,551 920,620Q920,695 867.5,747.5Q815,800 740,800L520,800Q487,800 463.5,776.5Q440,753 440,720L440,514L376,576L320,520L480,360L640,520L584,576L520,514L520,720Q520,720 520,720Q520,720 520,720L740,720Q782,720 811,691Q840,662 840,620Q840,578 811,549Q782,520 740,520L680,520L680,440Q680,357 621.5,298.5Q563,240 480,240Q397,240 338.5,298.5Q280,357 280,440L260,440Q202,440 161,481Q120,522 120,580Q120,638 161,679Q202,720 260,720L360,720L360,800L260,800ZM480,520L480,520L480,520Q480,520 480,520Q480,520 480,520L480,520L480,520Q480,520 480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520L480,520L480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520L480,520Z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M280,840L280,840Q280,840 280,840Q280,840 280,840L280,120Q280,120 280,120Q280,120 280,120L280,120Q280,120 280,120Q280,120 280,120L280,520L280,520Q280,520 280,590Q280,660 280,760L280,840ZM508.5,228.5Q520,217 520,200Q520,183 508.5,171.5Q497,160 480,160Q463,160 451.5,171.5Q440,183 440,200Q440,217 451.5,228.5Q463,240 480,240Q497,240 508.5,228.5ZM280,920Q247,920 223.5,896.5Q200,873 200,840L200,120Q200,87 223.5,63.5Q247,40 280,40L680,40Q713,40 736.5,63.5Q760,87 760,120L760,244Q778,251 789,266Q800,281 800,300L800,380Q800,399 789,414Q778,429 760,436L760,520L680,520L680,120Q680,120 680,120Q680,120 680,120L280,120Q280,120 280,120Q280,120 280,120L280,840Q280,840 280,840Q280,840 280,840L360,840L360,920L280,920ZM584,904L440,760L584,616L640,673L553,760L640,847L584,904ZM776,904L720,847L807,760L720,673L776,616L920,760L776,904Z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M500,840L444,784L586,642L444,500L500,444L642,586L784,444L840,500L698,642L840,784L784,840L642,698L500,840ZM280,840L280,760L360,760L360,840L280,840ZM200,200L120,200Q120,167 143.5,143.5Q167,120 200,120L200,200ZM280,200L280,120L360,120L360,200L280,200ZM440,200L440,120L520,120L520,200L440,200ZM600,200L600,120L680,120L680,200L600,200ZM760,200L760,120Q793,120 816.5,143.5Q840,167 840,200L760,200ZM200,760L200,840Q167,840 143.5,816.5Q120,793 120,760L200,760ZM120,680L120,600L200,600L200,680L120,680ZM120,520L120,440L200,440L200,520L120,520ZM120,360L120,280L200,280L200,360L120,360ZM760,360L760,280L840,280L840,360L760,360Z"/>
</vector>
+10
View File
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M240,880L240,710Q201,693 171.5,664.5Q142,636 121.5,600Q101,564 90.5,523Q80,482 80,440Q80,282 192,181Q304,80 480,80Q656,80 768,181Q880,282 880,440Q880,482 869.5,523Q859,564 838.5,600Q818,636 788.5,664.5Q759,693 720,710L720,880L240,880ZM320,800L360,800L360,720L440,720L440,800L520,800L520,720L600,720L600,800L640,800L640,658Q678,649 707.5,628Q737,607 757.5,578Q778,549 789,514Q800,479 800,440Q800,315 711.5,237.5Q623,160 480,160Q337,160 248.5,237.5Q160,315 160,440Q160,479 171,514Q182,549 202.5,578Q223,607 253,628Q283,649 320,658L320,800ZM420,600L540,600L480,480L420,600ZM340,520Q373,520 396.5,496.5Q420,473 420,440Q420,407 396.5,383.5Q373,360 340,360Q307,360 283.5,383.5Q260,407 260,440Q260,473 283.5,496.5Q307,520 340,520ZM620,520Q653,520 676.5,496.5Q700,473 700,440Q700,407 676.5,383.5Q653,360 620,360Q587,360 563.5,383.5Q540,407 540,440Q540,473 563.5,496.5Q587,520 620,520ZM480,800L480,800Q480,800 480,800Q480,800 480,800Q480,800 480,800Q480,800 480,800Q480,800 480,800Q480,800 480,800Q480,800 480,800Q480,800 480,800Q480,800 480,800Q480,800 480,800Q480,800 480,800Q480,800 480,800L480,800L480,800L480,800L480,800L480,800L480,800L480,800L480,800L480,800Z"/>
</vector>
+44 -2
View File
@@ -62,12 +62,16 @@
<string name="launcher_notification_update_success">Geode updated!</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">Unsupported Geometry Dash version.</string>
<string name="launcher_notification_compatibility_description">An update is required to receive Geode updates.</string> <string name="launcher_notification_compatibility_description">An update is required to receive Geode updates.</string>
<string name="launcher_notification_forwards_compatibility_description">Geode is currently unavailable.</string>
<string name="launcher_notification_forwards_compatibility">Unsupported Geometry Dash version.</string>
<string name="launcher_notification_update_cta">Tap for more information.</string> <string name="launcher_notification_update_cta">Tap for more information.</string>
<string name="launcher_self_update_failed_title">Update failed</string> <string name="launcher_self_update_failed_title">Update failed</string>
<string name="launcher_self_update_failed">Failed to install launcher update: %1$s.</string> <string name="launcher_self_update_failed">Failed to install launcher update: %1$s.</string>
<string name="launcher_self_update_progress">Updating Geode Launcher…</string> <string name="launcher_update_download_failed">Downloading update failed: %1$s</string>
<string name="launcher_self_update_progress">Installing update…</string>
<string name="launcher_starting_game">Starting Geometry Dash…</string> <string name="launcher_starting_game">Starting Geometry Dash…</string>
<string name="launcher_downloading_update">Downloading update…</string> <string name="launcher_downloading_update">Downloading update…</string>
@@ -75,6 +79,7 @@
<string name="launcher_cancelled_manual">Launch paused.</string> <string name="launcher_cancelled_manual">Launch paused.</string>
<string name="launcher_cancelled_navigated_away">Launch paused.</string> <string name="launcher_cancelled_navigated_away">Launch paused.</string>
<string name="launcher_cancelled_crash">Launch paused due to a crash.</string> <string name="launcher_cancelled_crash">Launch paused due to a crash.</string>
<string name="launcher_cancelled_crash_instant">Geode crashed.</string>
<string name="launcher_cancelled_error">Geode cannot be used.</string> <string name="launcher_cancelled_error">Geode cannot be used.</string>
<string name="launcher_cancelled_outdated">A Geometry Dash update is required.</string> <string name="launcher_cancelled_outdated">A Geometry Dash update is required.</string>
<string name="launcher_cancelled_restart">Restart</string> <string name="launcher_cancelled_restart">Restart</string>
@@ -140,6 +145,7 @@
<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="preferences_view_crashes">View crash dumps</string>
<string name="preferences_view_geode_logs">View Geode logs</string>
<string name="preference_theme_name">Theme</string> <string name="preference_theme_name">Theme</string>
<string name="preference_theme_select">Select theme</string> <string name="preference_theme_select">Select theme</string>
@@ -229,7 +235,12 @@
<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">Last checked %1$s</string> <string name="preference_check_for_updates_description">Last checked %1$s</string>
<string name="preference_check_for_updates_failed">Update failed. See logs for more details.</string> <string name="preference_check_for_updates_failed">Update failed.</string>
<string name="preference_check_for_updates_failed_action">Help</string>
<string name="preference_check_for_updates_help_title">Update failed.</string>
<string name="preference_check_for_updates_help_copy">Copy</string>
<string name="preference_check_for_updates_none_found">No updates found.</string> <string name="preference_check_for_updates_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>
<string name="preference_check_for_updates_downloading">Downloading update…</string> <string name="preference_check_for_updates_downloading">Downloading update…</string>
@@ -241,10 +252,34 @@
<string name="preference_launch_safe_mode">Launch in safe mode</string> <string name="preference_launch_safe_mode">Launch in safe mode</string>
<string name="preference_launch_safe_mode_confirm_title">Start in safe mode?</string> <string name="preference_launch_safe_mode_confirm_title">Start in safe mode?</string>
<string name="preference_backup_cloud">Backup save to cloud</string>
<string name="preference_backup_confirm_title">Backup save data?</string>
<string name="preference_backup_in_progress">Backing up data…</string>
<string name="preference_backup_cancelling">Cancelling backup…</string>
<string name="preference_backup_confirm_description">This will backup your save data using your in-game account. You can then access it on an unmodified version of the game through the account backup menu.\nContinuing will overwrite any save data you currently have on the Geometry Dash servers!</string>
<string name="preference_backup_save_missing">Failed to find save data.</string>
<string name="preference_backup_save_decode_failed">Failed to decode save file.</string>
<string name="preference_backup_save_too_large">Save file is too large.</string>
<string name="preference_backup_save_details_missing">Failed to find account information.</string>
<string name="preference_backup_success">Backup success!</string>
<string name="preference_backup_cancelled">Backup cancelled.</string>
<string name="preference_backup_failed_generic">An unknown error has occurred.</string>
<string name="preference_backup_failed_invalid_login">Invalid login information.</string>
<string name="preference_backup_failed_network">A network error has occurred. Please try again later.</string>
<string name="preference_backup_failed_title">Backup failed</string>
<string name="preference_backup_failed">Backup failed with error code %1$d.</string>
<string name="preference_launch_arguments_name">Launch arguments</string> <string name="preference_launch_arguments_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_disable_update_cache">Disable update cache</string>
<string name="preference_open_developer_options">Open developer options</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_developer_options_dialog_title">Enable developer options?</string>
@@ -290,6 +325,8 @@
<string name="title_activity_crash_logs">Crash dumps</string> <string name="title_activity_crash_logs">Crash dumps</string>
<string name="application_crashes_no_dumps">No crash dumps available.</string> <string name="application_crashes_no_dumps">No crash dumps available.</string>
<string name="application_crashes_delete_one">Delete</string> <string name="application_crashes_delete_one">Delete</string>
<string name="application_crashes_clear_indicator">Ignore last crash</string>
<string name="application_crashes_create_indicator">Fake crash</string>
<!-- ApplicationIconActivity strings --> <!-- ApplicationIconActivity strings -->
<string name="title_activity_application_icon">Application icons</string> <string name="title_activity_application_icon">Application icons</string>
@@ -300,4 +337,9 @@
<string name="application_icon_trans">Transgender</string> <string name="application_icon_trans">Transgender</string>
<string name="application_icon_confirm">Change icon to %1$s?</string> <string name="application_icon_confirm">Change icon to %1$s?</string>
<string name="application_icon_confirm_description">Geode Launcher will close itself to change the application icon.</string> <string name="application_icon_confirm_description">Geode Launcher will close itself to change the application icon.</string>
<!-- GeodeLogsListingActivity strings -->
<string name="title_activity_geode_logs">Geode logs</string>
<string name="application_geode_logs_no_logs">No logs available.</string>
<string name="application_geode_logs_empty">File was empty.</string>
</resources> </resources>
+5 -5
View File
@@ -1,13 +1,13 @@
buildscript { buildscript {
val composeBOM by extra("2026.01.01") val composeBOM by extra("2026.02.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 "9.0.0" apply false id("com.android.application") version "9.0.1" apply false
id("com.android.library") version "9.0.0" apply false id("com.android.library") version "9.0.1" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.3.0" apply false id("org.jetbrains.kotlin.plugin.serialization") version "2.3.10" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.3.0" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.3.10" apply false
} }
tasks.register<Delete>("clean") { tasks.register<Delete>("clean") {
+1 -1
View File
@@ -1,7 +1,7 @@
#Wed Jan 28 04:12:33 MST 2026 #Wed Jan 28 04:12:33 MST 2026
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME